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
39 changes: 39 additions & 0 deletions src/__tests__/renderer/components/RightPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,45 @@ describe('RightPanel', () => {
expect(spy).toHaveBeenCalledWith('right');
});

it('keeps Right Bar focus when blur relatedTarget is null but a child is still active', async () => {
useUIStore.setState({ activeFocus: 'right' });
const spy = vi.spyOn(useUIStore.getState(), 'setActiveFocus');
const props = createDefaultProps();
const { container } = render(<RightPanel {...props} />);

const panel = container.firstChild as HTMLElement;
const contentArea = container.querySelector('.overflow-y-auto') as HTMLElement;
contentArea.focus();
spy.mockClear();

fireEvent.blur(panel, { relatedTarget: null });
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});

expect(spy).not.toHaveBeenCalledWith('main');
expect(useUIStore.getState().activeFocus).toBe('right');
});

it('hands focus to Main when blur really leaves the panel', async () => {
useUIStore.setState({ activeFocus: 'right' });
const outside = document.createElement('button');
document.body.appendChild(outside);
const props = createDefaultProps();
const { container } = render(<RightPanel {...props} />);

const panel = container.firstChild as HTMLElement;
panel.focus();
fireEvent.blur(panel, { relatedTarget: outside });
outside.focus();
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});

expect(useUIStore.getState().activeFocus).toBe('main');
outside.remove();
});

it('should show focus ring when activeFocus is right', () => {
useUIStore.setState({ activeFocus: 'right' });
const props = createDefaultProps();
Expand Down
106 changes: 106 additions & 0 deletions src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useSettingsStore } from '../../../renderer/stores/settingsStore';
import { useModalStore } from '../../../renderer/stores/modalStore';
import { useUIStore } from '../../../renderer/stores/uiStore';
import { useSessionStore } from '../../../renderer/stores/sessionStore';
import { groupChatOutputSearchKey } from '../../../renderer/utils/outputSearch';

// Cmd+Shift+J delegates to the shared tile action. Mocked so the test asserts the
// wiring rather than re-running the layout transform (covered in tileNewTab.test).
Expand Down Expand Up @@ -3860,6 +3861,57 @@ describe('useMainKeyboardHandler', () => {
expect(document.activeElement).toBe(searchInput);
});

it('does not steal Cmd+F back to Find when the Right Bar is focused', () => {
useUIStore.getState().setOutputSearchOpen(SEARCH_KEY, true);
const setFileTreeFilterOpen = vi.fn();

const { result } = renderHook(() => useMainKeyboardHandler());
result.current.keyboardHandlerRef.current = createMockContext({
// Find overlay is registered while the bar is open.
hasOpenLayers: () => true,
activeFocus: 'right',
activeRightTab: 'files',
fileTreeFilterOpen: false,
setFileTreeFilterOpen,
fileTreeFilterInputRef: { current: null },
});

act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'f',
metaKey: true,
bubbles: true,
})
);
});

expect(document.activeElement).not.toBe(searchInput);
expect(setFileTreeFilterOpen).toHaveBeenCalledWith(true);
});

it('does not steal Cmd+F back to Find when the Left Bar is focused', () => {
useUIStore.getState().setOutputSearchOpen(SEARCH_KEY, true);

const { result } = renderHook(() => useMainKeyboardHandler());
result.current.keyboardHandlerRef.current = createMockContext({
hasOpenLayers: () => true,
activeFocus: 'sidebar',
});

act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'f',
metaKey: true,
bubbles: true,
})
);
});

expect(document.activeElement).not.toBe(searchInput);
});

it('does not steal Cmd+F focus when output search is closed', () => {
useUIStore.getState().setOutputSearchOpen(SEARCH_KEY, false);

Expand Down Expand Up @@ -4063,6 +4115,60 @@ describe('useMainKeyboardHandler', () => {
expect(evt.defaultPrevented).toBe(false);
});
});

describe('group chat Cmd+F vs Opt+Cmd+F', () => {
const GROUP_ID = 'gc-find';
const SEARCH_KEY = groupChatOutputSearchKey(GROUP_ID);

afterEach(() => {
useUIStore.getState().setOutputSearchOpen(SEARCH_KEY, false);
});

it('opens group Find on Cmd+F', () => {
const { result } = renderHook(() => useMainKeyboardHandler());
result.current.keyboardHandlerRef.current = createMockContext({
activeGroupChatId: GROUP_ID,
activeFocus: 'main',
isShortcut: () => false,
});

act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'f',
metaKey: true,
bubbles: true,
})
);
});

expect(useUIStore.getState().outputSearchByKey[SEARCH_KEY]?.open).toBe(true);
});

it('does not open group Find on Opt+Cmd+F', () => {
const { result } = renderHook(() => useMainKeyboardHandler());
result.current.keyboardHandlerRef.current = createMockContext({
activeGroupChatId: GROUP_ID,
activeFocus: 'main',
isShortcut: (_e: KeyboardEvent, id: string) => id === 'searchAllTabs',
handleOpenCrossTabSearch: vi.fn(),
});

act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'f',
code: 'KeyF',
altKey: true,
metaKey: true,
bubbles: true,
})
);
});

expect(useUIStore.getState().outputSearchByKey[SEARCH_KEY]?.open).toBeFalsy();
});
});
});

/**
Expand Down
80 changes: 80 additions & 0 deletions src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,84 @@ describe('useOutputSearchMatching', () => {
unmount();
container.remove();
});

it('rescans when contentRevision changes after the DOM text updates', async () => {
const container = mountContainer('<p>alpha</p>');
const { result, rerender, unmount } = renderHook(
({ contentRevision }: { contentRevision: number }) => {
const containerRef = useRef<HTMLElement | null>(container);
return useOutputSearchMatching({
containerRef,
outputSearchOpen: true,
outputSearchRegex: false,
debouncedSearchQuery: 'alpha',
contentRevision,
});
},
{ initialProps: { contentRevision: 1 } }
);

await waitFor(() => {
expect(result.current.totalMatches).toBe(1);
});

container.innerHTML = '<p>alpha alpha</p>';
rerender({ contentRevision: 2 });

await waitFor(() => {
expect(result.current.totalMatches).toBe(2);
});

unmount();
container.remove();
});

it('sets regexError and reports zero matches for an invalid regex', async () => {
const container = mountContainer('<p>alpha</p>');
const { result, unmount } = renderHook(() => {
const containerRef = useRef<HTMLElement | null>(container);
return useOutputSearchMatching({
containerRef,
outputSearchOpen: true,
outputSearchRegex: true,
debouncedSearchQuery: '[',
contentRevision: 1,
});
});

await waitFor(() => {
expect(result.current.regexError).toBeTruthy();
});
expect(result.current.totalMatches).toBe(0);

unmount();
container.remove();
});

it('selects the match inside the jumped-to row', async () => {
const container = mountContainer(
'<div data-log-id="a">alpha</div><div data-log-id="b">alpha</div>'
);
const jumpRef = { current: 'b' as string | null };
const { result, unmount } = renderHook(() => {
const containerRef = useRef<HTMLElement | null>(container);
return useOutputSearchMatching({
containerRef,
outputSearchOpen: true,
outputSearchRegex: false,
debouncedSearchQuery: 'alpha',
contentRevision: 1,
pendingJumpMatchIdRef: jumpRef,
jumpIdAttribute: 'data-log-id',
});
});

await waitFor(() => {
expect(result.current.currentMatchIndex).toBe(1);
});
expect(jumpRef.current).toBeNull();

unmount();
container.remove();
});
});
58 changes: 58 additions & 0 deletions src/__tests__/renderer/utils/fileTree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { buildFileTreeFromPaths } from '../../../renderer/utils/fileTree';
import type { FileNode } from '../../../renderer/types/fileTree';

const names = (nodes: FileNode[]) => nodes.map((n) => n.name);
const folder = (nodes: FileNode[], name: string) =>
nodes.find((n) => n.name === name && n.type === 'folder');

describe('buildFileTreeFromPaths', () => {
it('returns an empty tree for no paths', () => {
expect(buildFileTreeFromPaths([])).toEqual([]);
});

it('places a root-level file at the top level', () => {
const tree = buildFileTreeFromPaths(['README.md']);
expect(tree).toEqual([{ name: 'README.md', type: 'file', fullPath: 'README.md' }]);
});

it('infers folders from the path segments', () => {
const tree = buildFileTreeFromPaths(['docs/guides/setup.md']);
const docs = folder(tree, 'docs');
expect(docs?.isFolder).toBe(true);
const guides = folder(docs!.children!, 'guides');
expect(names(guides!.children!)).toEqual(['setup.md']);
});

it('keeps fullPath as the original path, not just the basename', () => {
const tree = buildFileTreeFromPaths(['docs/guides/setup.md']);
const leaf = folder(folder(tree, 'docs')!.children!, 'guides')!.children![0];
// Wiki-link resolution matches on fullPath, so a basename here would make
// two same-named files in different folders indistinguishable.
expect(leaf.fullPath).toBe('docs/guides/setup.md');
});

it('reuses a shared folder rather than duplicating it', () => {
const tree = buildFileTreeFromPaths(['docs/a.md', 'docs/b.md']);
expect(tree.filter((n) => n.name === 'docs')).toHaveLength(1);
expect(names(folder(tree, 'docs')!.children!)).toEqual(['a.md', 'b.md']);
});

it('reuses shared ancestors across diverging branches', () => {
const tree = buildFileTreeFromPaths(['docs/guides/a.md', 'docs/specs/b.md']);
const docs = folder(tree, 'docs')!;
expect(names(docs.children!).sort()).toEqual(['guides', 'specs']);
});

it('skips empty path entries instead of creating blank nodes', () => {
const tree = buildFileTreeFromPaths(['', 'docs/a.md', '']);
expect(tree).toHaveLength(1);
expect(names(tree)).toEqual(['docs']);
});

it('keeps two same-named files in different folders separate', () => {
const tree = buildFileTreeFromPaths(['docs/index.md', 'specs/index.md']);
expect(folder(tree, 'docs')!.children![0].fullPath).toBe('docs/index.md');
expect(folder(tree, 'specs')!.children![0].fullPath).toBe('specs/index.md');
});
});
29 changes: 28 additions & 1 deletion src/__tests__/renderer/utils/outputSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import {
outputSearchKeyFor,
groupChatOutputSearchKey,
groupChatSearchContentRevision,
getActiveOutputSearchKey,
} from '../../../renderer/utils/outputSearch';
import { useGroupChatStore } from '../../../renderer/stores/groupChatStore';
Expand All @@ -14,7 +15,7 @@ import { useSessionStore } from '../../../renderer/stores/sessionStore';
describe('outputSearch keys', () => {
beforeEach(() => {
useGroupChatStore.setState({ activeGroupChatId: null });
useSessionStore.setState({ sessions: [], activeSessionId: null });
useSessionStore.setState({ sessions: [], activeSessionId: '' });
});

it('builds a stable agent+tab key', () => {
Expand Down Expand Up @@ -56,3 +57,29 @@ describe('outputSearch keys', () => {
expect(getActiveOutputSearchKey()).toBe('sess-1::tab-a');
});
});

describe('groupChatSearchContentRevision', () => {
const query = 'alpha';

it('changes when an existing message text grows (streaming) even if count stays 1', () => {
const before = groupChatSearchContentRevision([{ content: 'hel' }], query, true);
const after = groupChatSearchContentRevision([{ content: 'hello world' }], query, true);
expect(before).not.toBe(after);
});

it('stays stable when neither text length, query, nor open flag changes', () => {
const a = groupChatSearchContentRevision([{ content: 'hello' }], query, true);
const b = groupChatSearchContentRevision([{ content: 'hello' }], query, true);
expect(a).toBe(b);
});

it('still changes when a new message arrives', () => {
const before = groupChatSearchContentRevision([{ content: 'hello' }], query, true);
const after = groupChatSearchContentRevision(
[{ content: 'hello' }, { content: 'alpha' }],
query,
true
);
expect(before).not.toBe(after);
});
});
Loading