Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions packages/app/src/components/CommandPalette.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,12 @@ describe('CommandPalette DOM behavior', () => {
expect(switchProject.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
expect(document.body.textContent).not.toContain('Start fresh in a new folder');

expect(screen.getByTestId('command-palette-new-file').textContent).toMatch(/⌘ N|Ctrl N/);
expect(screen.getByTestId('command-palette-new-folder').textContent).toMatch(
/⇧⌘ N|Ctrl Shift N/,
);
expect(screen.getByTestId('command-palette-open-folder').textContent).toMatch(/⌘ O|Ctrl O/);

fireEvent.click(switchProject);
await waitFor(() => expect(bridge.navigator.open).toHaveBeenCalledTimes(1));

Expand Down Expand Up @@ -309,6 +315,15 @@ describe('CommandPalette DOM behavior', () => {
expect(screen.queryByTestId('command-palette-switch-project')).toBeNull();
});

test('new-folder shortcut is desktop-only while new-file shortcut is always visible', async () => {
await renderPalette({ bridge: null });

expect(screen.getByTestId('command-palette-new-file').textContent).toMatch(/⌘ N|Ctrl N/);
expect(screen.getByTestId('command-palette-new-folder').textContent).not.toMatch(
/⇧⌘ N|Ctrl Shift N/,
);
});

test('settings command is searchable by preferences/config, closes the palette, and routes through the canonical hash', async () => {
const { onOpenChange } = await renderPalette({ bridge: null });

Expand Down Expand Up @@ -561,4 +576,106 @@ describe('NavigationItem path subtitle', () => {
expect(rowA.textContent).toContain('reports/q3/data.csv');
expect(rowB.textContent).toContain('exports/legacy/data.csv');
});

test('file and folder rows render sidebar-aligned icons and extension badges', async () => {
const { NavigationItem } = await import('./CommandPalette');
render(
<>
<NavigationItem
entry={{ kind: 'file' as const, path: 'notes/readme', name: 'readme' }}
onSelect={() => {}}
/>
<NavigationItem
entry={{
kind: 'file' as const,
path: 'docs/component',
name: 'component',
docExt: '.mdx',
}}
onSelect={() => {}}
/>
<NavigationItem
entry={{
kind: 'file' as const,
path: 'assets/photo.png',
name: 'photo.png',
bodyIndexed: false,
}}
onSelect={() => {}}
/>
<NavigationItem
entry={{
kind: 'file' as const,
path: 'media/demo.mp4',
name: 'demo.mp4',
bodyIndexed: false,
}}
onSelect={() => {}}
/>
<NavigationItem
entry={{
kind: 'file' as const,
path: 'audio/theme.mp3',
name: 'theme.mp3',
bodyIndexed: false,
}}
onSelect={() => {}}
/>
<NavigationItem
entry={{
kind: 'file' as const,
path: 'src/index.ts',
name: 'index.ts',
bodyIndexed: false,
}}
onSelect={() => {}}
/>
<NavigationItem
entry={{
kind: 'file' as const,
path: 'recents/screenshot.png',
name: 'screenshot.png',
}}
onSelect={() => {}}
/>
<NavigationItem
entry={{ kind: 'folder' as const, path: 'docs', name: 'docs' }}
onSelect={() => {}}
/>
</>,
);

const markdownRow = screen.getByTestId('command-palette-nav-file-notes/readme');
expect(markdownRow.querySelector('[data-testid="file-entry-icon-markdown"]')).not.toBeNull();
expect(markdownRow.querySelector('[data-testid="file-entry-extension-badge"]')).toBeNull();

const mdxRow = screen.getByTestId('command-palette-nav-file-docs/component');
expect(mdxRow.querySelector('[data-testid="file-entry-icon-markdown"]')).not.toBeNull();
expect(mdxRow.textContent).toContain('MDX');

const pngRow = screen.getByTestId('command-palette-nav-file-assets/photo.png');
expect(pngRow.querySelector('[data-testid="file-entry-icon-image"]')).not.toBeNull();
expect(pngRow.textContent).toContain('PNG');

const videoRow = screen.getByTestId('command-palette-nav-file-media/demo.mp4');
expect(videoRow.querySelector('[data-testid="file-entry-icon-video"]')).not.toBeNull();
expect(videoRow.textContent).toContain('MP4');

const audioRow = screen.getByTestId('command-palette-nav-file-audio/theme.mp3');
expect(audioRow.querySelector('[data-testid="file-entry-icon-audio"]')).not.toBeNull();
expect(audioRow.textContent).toContain('MP3');

const genericFileRow = screen.getByTestId('command-palette-nav-file-src/index.ts');
expect(genericFileRow.querySelector('[data-testid="file-entry-icon-file"]')).not.toBeNull();
expect(genericFileRow.querySelector('[data-testid="file-entry-icon-image"]')).toBeNull();
expect(genericFileRow.textContent).toContain('TS');

const recentPngRow = screen.getByTestId('command-palette-nav-file-recents/screenshot.png');
expect(recentPngRow.querySelector('[data-testid="file-entry-icon-image"]')).not.toBeNull();
expect(recentPngRow.querySelector('[data-testid="file-entry-icon-markdown"]')).toBeNull();

const folderRow = screen.getByTestId('command-palette-nav-folder-docs');
expect(folderRow.querySelector('[data-file-entry-icon="folder"]')).not.toBeNull();
expect(folderRow.querySelector('[data-testid="file-entry-extension-badge"]')).toBeNull();
});
});
17 changes: 14 additions & 3 deletions packages/app/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
type TagDocEntry,
} from '@/components/command-palette-tag-search';
import { requestDocPanelTab } from '@/components/doc-panel-events';
import { FileEntryIcon } from '@/components/file-entry-icon';
import { defaultInitialDir } from '@/components/file-tree-utils';
import { NewItemDialog } from '@/components/NewItemDialog';
import { usePageList } from '@/components/PageListContext';
Expand All @@ -72,7 +73,6 @@ import {
} from '@/components/ui/command';
import { useDocumentContext } from '@/editor/DocumentContext';
import type { TagSummaryEntry } from '@/editor/extensions/tag-suggestion';
import { getFileIcon } from '@/editor/registry/file-icons';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { useSemanticSearchStatus } from '@/hooks/use-semantic-search-status';
import type { OkDesktopBridge, RecentProjectEntry } from '@/lib/desktop-bridge-types';
Expand Down Expand Up @@ -128,10 +128,11 @@ export function NavigationItem({
onSelect: () => void;
disabled?: boolean;
}) {
const Icon = getFileIcon(entry);
const title =
'title' in entry && entry.title ? entry.title : (entry.path.split('/').pop() ?? entry.path);
const snippet = 'snippet' in entry ? entry.snippet : undefined;
const docExt = 'docExt' in entry ? entry.docExt : undefined;
const bodyIndexed = 'bodyIndexed' in entry ? entry.bodyIndexed : undefined;

return (
<CommandItem
Expand All @@ -141,7 +142,13 @@ export function NavigationItem({
data-testid={`command-palette-nav-${entry.kind}-${entry.path}`}
className="items-start"
>
<Icon className="mt-0.5" />
<FileEntryIcon
bodyIndexed={bodyIndexed}
className="mt-0.5 size-4"
docExt={docExt}
kind={entry.kind}
path={entry.path}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium">
<HighlightedText query={query} text={title} />
Expand Down Expand Up @@ -1043,6 +1050,7 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
<span>
<Trans>New file</Trans>
</span>
<CommandShortcut>{formatShortcut('new-item')}</CommandShortcut>
</CommandItem>
) : null}
{showCreateFolder ? (
Expand All @@ -1058,6 +1066,9 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
<span>
<Trans>New folder</Trans>
</span>
{bridge ? (
<CommandShortcut>{formatShortcut('new-folder')}</CommandShortcut>
) : null}
</CommandItem>
) : null}
{showGraphCommand ? (
Expand Down
24 changes: 21 additions & 3 deletions packages/app/src/components/ComposerContextChips.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,33 @@ describe('ComposerContextChips', () => {
expect(chip.querySelectorAll('button').length).toBe(1);
});

test('the leading icon is type-aware: a folder chip shows a different glyph than a page chip', async () => {
await renderChips(['specs/foo', 'notes.md']);
test('the leading icon is type-aware across folders, pages, video, and audio', async () => {
await renderChips(['specs/foo', 'notes.md', 'clips/demo.mp4', 'audio/theme.mp3']);
const folderChip = screen.getByTestId('composer-context-chip-file-specs/foo');
const pageChip = screen.getByTestId('composer-context-chip-file-notes.md');
const videoChip = screen.getByTestId('composer-context-chip-file-clips/demo.mp4');
const audioChip = screen.getByTestId('composer-context-chip-file-audio/theme.mp3');
expect(
folderChip.querySelector('button > span:first-child [data-file-entry-icon="folder"]'),
).not.toBeNull();
expect(
pageChip.querySelector('button > span:first-child [data-testid="file-entry-icon-markdown"]'),
).not.toBeNull();
expect(
videoChip.querySelector('button > span:first-child [data-testid="file-entry-icon-video"]'),
).not.toBeNull();
expect(
audioChip.querySelector('button > span:first-child [data-testid="file-entry-icon-audio"]'),
).not.toBeNull();
const folderIcon = folderChip.querySelector('button svg')?.outerHTML;
const pageIcon = pageChip.querySelector('button svg')?.outerHTML;
const videoIcon = videoChip.querySelector('button svg')?.outerHTML;
const audioIcon = audioChip.querySelector('button svg')?.outerHTML;
expect(folderIcon).toBeDefined();
expect(pageIcon).toBeDefined();
expect(folderIcon).not.toBe(pageIcon);
expect(videoIcon).toBeDefined();
expect(audioIcon).toBeDefined();
expect(new Set([folderIcon, pageIcon, videoIcon, audioIcon]).size).toBe(4);
});

test('renders nothing when the file set is empty', async () => {
Expand Down
10 changes: 4 additions & 6 deletions packages/app/src/components/ComposerContextChips.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useLingui } from '@lingui/react/macro';
import { X } from 'lucide-react';
import type { ReactNode } from 'react';
import { FileEntryPathIcon } from '@/components/file-entry-icon';
import { Button } from '@/components/ui/button';
import { getFileIcon, mentionPathToDescriptor } from '@/editor/registry/file-icons';
import { cn } from '@/lib/utils';

/** Last path segment of a workspace-relative path — the compact chip label
Expand All @@ -17,7 +17,6 @@ function FileChip({ path, onRemove }: { path: string; onRemove: () => void }) {
const { t } = useLingui();
const label = chipBasename(path);
const removeLabel = t`Remove ${label} from context`;
const FileIcon = getFileIcon(mentionPathToDescriptor(path));
return (
<span
data-testid={`composer-context-chip-file-${path}`}
Expand Down Expand Up @@ -47,10 +46,9 @@ function FileChip({ path, onRemove }: { path: string; onRemove: () => void }) {
}}
className="group/remove relative size-3.5 shrink-0 rounded-sm text-muted-foreground/80 hover:text-foreground"
>
<FileIcon
className="absolute top-1/2 left-1/2 size-3 -translate-x-1/2 -translate-y-1/2 opacity-100 transition-opacity duration-150 ease-out group-hover/chip:opacity-0 group-focus-within/chip:opacity-0 motion-reduce:transition-none"
aria-hidden
/>
<span className="absolute top-1/2 left-1/2 inline-flex size-3 -translate-x-1/2 -translate-y-1/2 opacity-100 transition-opacity duration-150 ease-out group-hover/chip:opacity-0 group-focus-within/chip:opacity-0 motion-reduce:transition-none">
<FileEntryPathIcon path={path} className="size-3" />
</span>
<X
className="absolute top-1/2 left-1/2 size-3 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-150 ease-out group-hover/chip:opacity-100 group-focus-within/chip:opacity-100 motion-reduce:transition-none"
aria-hidden
Expand Down
7 changes: 5 additions & 2 deletions packages/app/src/components/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ import {
} from 'react';
import { toast } from 'sonner';
import { DeleteConfirmationDialog } from '@/components/DeleteConfirmationDialog';
import {
MARKDOWN_FILE_ICON_PATH_D,
MARKDOWN_FILE_ICON_VIEWBOX,
} from '@/components/file-entry-icon';
import {
collectTreeFolderPathsFromDocuments,
computeTreeAncestorPaths,
Expand Down Expand Up @@ -283,8 +287,7 @@ const AGENT_FILE_NAMES = new Set(['agents', 'agent', 'claude', 'skill']);
const LINK_DECORATION_ICON_ID = 'ok-file-tree-link-decoration';
const AGENT_DECORATION_ICON_ID = 'ok-file-tree-agent-decoration';
const MARKDOWN_FILE_ICON_ID = 'ok-file-tree-markdown';
const MARKDOWN_FILE_ICON_VIEWBOX = '0 0 32 32';
const MARKDOWN_FILE_ICON_SYMBOL = `<symbol id="${MARKDOWN_FILE_ICON_ID}" viewBox="${MARKDOWN_FILE_ICON_VIEWBOX}" fill="currentColor"><path d="M26.7075 10.2925L19.7075 3.2925C19.6146 3.19967 19.5042 3.12605 19.3829 3.07586C19.2615 3.02568 19.1314 2.9999 19 3H7C6.46957 3 5.96086 3.21071 5.58579 3.58579C5.21071 3.96086 5 4.46957 5 5V14C5 14.2652 5.10536 14.5196 5.29289 14.7071C5.48043 14.8946 5.73478 15 6 15C6.26522 15 6.51957 14.8946 6.70711 14.7071C6.89464 14.5196 7 14.2652 7 14V5H18V11C18 11.2652 18.1054 11.5196 18.2929 11.7071C18.4804 11.8946 18.7348 12 19 12H25V28C25 28.2652 25.1054 28.5196 25.2929 28.7071C25.4804 28.8946 25.7348 29 26 29C26.2652 29 26.5196 28.8946 26.7071 28.7071C26.8946 28.5196 27 28.2652 27 28V11C27.0001 10.8686 26.9743 10.7385 26.9241 10.6172C26.8739 10.4958 26.8003 10.3854 26.7075 10.2925ZM20 6.41375L23.5863 10H20V6.41375ZM18 18H16C15.7348 18 15.4804 18.1054 15.2929 18.2929C15.1054 18.4804 15 18.7348 15 19V26C15 26.2652 15.1054 26.5196 15.2929 26.7071C15.4804 26.8946 15.7348 27 16 27H18C19.1935 27 20.3381 26.5259 21.182 25.682C22.0259 24.8381 22.5 23.6935 22.5 22.5C22.5 21.3065 22.0259 20.1619 21.182 19.318C20.3381 18.4741 19.1935 18 18 18ZM18 25H17V20H18C18.663 20 19.2989 20.2634 19.7678 20.7322C20.2366 21.2011 20.5 21.837 20.5 22.5C20.5 23.163 20.2366 23.7989 19.7678 24.2678C19.2989 24.7366 18.663 25 18 25ZM13 19V26C13 26.2652 12.8946 26.5196 12.7071 26.7071C12.5196 26.8946 12.2652 27 12 27C11.7348 27 11.4804 26.8946 11.2929 26.7071C11.1054 26.5196 11 26.2652 11 26V22.1725L9.31875 24.5737C9.22652 24.7053 9.10396 24.8126 8.96144 24.8868C8.81892 24.9609 8.66064 24.9996 8.5 24.9996C8.33936 24.9996 8.18108 24.9609 8.03856 24.8868C7.89604 24.8126 7.77348 24.7053 7.68125 24.5737L6 22.1725V26C6 26.2652 5.89464 26.5196 5.70711 26.7071C5.51957 26.8946 5.26522 27 5 27C4.73478 27 4.48043 26.8946 4.29289 26.7071C4.10536 26.5196 4 26.2652 4 26V19C4.00009 18.7874 4.06791 18.5804 4.19363 18.409C4.31935 18.2376 4.49642 18.1107 4.69915 18.0467C4.90188 17.9828 5.11971 17.9851 5.32104 18.0533C5.52236 18.1216 5.6967 18.2522 5.81875 18.4263L8.5 22.2563L11.1812 18.4263C11.3033 18.2522 11.4776 18.1216 11.679 18.0533C11.8803 17.9851 12.0981 17.9828 12.3008 18.0467C12.5036 18.1107 12.6807 18.2376 12.8064 18.409C12.9321 18.5804 12.9999 18.7874 13 19Z"/></symbol>`;
const MARKDOWN_FILE_ICON_SYMBOL = `<symbol id="${MARKDOWN_FILE_ICON_ID}" viewBox="${MARKDOWN_FILE_ICON_VIEWBOX}" fill="currentColor"><path d="${MARKDOWN_FILE_ICON_PATH_D}"/></symbol>`;

type IconNode = [string, Record<string, string>][];

Expand Down
37 changes: 25 additions & 12 deletions packages/app/src/components/command-palette-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ describe('buildWorkspaceEntries', () => {
path: 'data/example.csv',
name: 'example.csv',
bodyIndexed: false,
assetExt: 'csv',
mediaKind: null,
},
{ kind: 'folder', path: 'notes', name: 'notes' },
{ kind: 'file', path: 'notes/guide', name: 'guide' },
Expand All @@ -57,11 +55,27 @@ describe('buildWorkspaceEntries', () => {
path: 'packages/app/src/index.ts',
name: 'index.ts',
bodyIndexed: false,
assetExt: 'ts',
mediaKind: 'text',
},
]);
});

test('threads page docExt metadata onto markdown page entries', () => {
const entries = buildWorkspaceEntries(
new Set(['docs/component']),
new Set(),
new Map(),
new Map([
['docs/component', { size: 100, modified: '2026-06-24T00:00:00.000Z', docExt: '.mdx' }],
]),
);

expect(entries[0]).toMatchObject({
kind: 'file',
path: 'docs/component',
name: 'component',
docExt: '.mdx',
});
});
test('skips a non-markdown file already present in pages', () => {
const entries = buildWorkspaceEntries(
new Set(['data/example.csv']),
Expand Down Expand Up @@ -315,15 +329,15 @@ describe('fetchWorkspaceSearchEntries', () => {
});
});

test('maps a kind:file server row to a client kind:file entry', async () => {
test('maps a kind:file server row to a client kind:file name-only entry', async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
results: [
{
kind: 'file',
path: 'data/example.csv',
title: 'data/example.csv',
path: 'assets/photo.png',
title: 'assets/photo.png',
score: 7,
},
],
Expand All @@ -336,12 +350,11 @@ describe('fetchWorkspaceSearchEntries', () => {
expect(entries).toEqual([
{
kind: 'file',
path: 'data/example.csv',
name: 'example.csv',
title: 'data/example.csv',
path: 'assets/photo.png',
name: 'photo.png',
bodyIndexed: false,
title: 'assets/photo.png',
score: 7,
assetExt: 'csv',
mediaKind: null,
},
]);
});
Expand Down
Loading