diff --git a/src/__tests__/renderer/components/RightPanel.test.tsx b/src/__tests__/renderer/components/RightPanel.test.tsx index cba6c6aa9d..1a608c36fd 100644 --- a/src/__tests__/renderer/components/RightPanel.test.tsx +++ b/src/__tests__/renderer/components/RightPanel.test.tsx @@ -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(); + + 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((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(); + + const panel = container.firstChild as HTMLElement; + panel.focus(); + fireEvent.blur(panel, { relatedTarget: outside }); + outside.focus(); + await act(async () => { + await new Promise((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(); diff --git a/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts b/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts index 13d56ab9c0..2c6a755400 100644 --- a/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts +++ b/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts @@ -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). @@ -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); @@ -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(); + }); + }); }); /** diff --git a/src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts b/src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts index 34676038c8..a5f0398ad2 100644 --- a/src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts +++ b/src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts @@ -68,4 +68,84 @@ describe('useOutputSearchMatching', () => { unmount(); container.remove(); }); + + it('rescans when contentRevision changes after the DOM text updates', async () => { + const container = mountContainer('

alpha

'); + const { result, rerender, unmount } = renderHook( + ({ contentRevision }: { contentRevision: number }) => { + const containerRef = useRef(container); + return useOutputSearchMatching({ + containerRef, + outputSearchOpen: true, + outputSearchRegex: false, + debouncedSearchQuery: 'alpha', + contentRevision, + }); + }, + { initialProps: { contentRevision: 1 } } + ); + + await waitFor(() => { + expect(result.current.totalMatches).toBe(1); + }); + + container.innerHTML = '

alpha alpha

'; + 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('

alpha

'); + const { result, unmount } = renderHook(() => { + const containerRef = useRef(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( + '
alpha
alpha
' + ); + const jumpRef = { current: 'b' as string | null }; + const { result, unmount } = renderHook(() => { + const containerRef = useRef(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(); + }); }); diff --git a/src/__tests__/renderer/utils/fileTree.test.ts b/src/__tests__/renderer/utils/fileTree.test.ts new file mode 100644 index 0000000000..aac6c1875f --- /dev/null +++ b/src/__tests__/renderer/utils/fileTree.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/renderer/utils/outputSearch.test.ts b/src/__tests__/renderer/utils/outputSearch.test.ts index bbda6ad27c..739767fb02 100644 --- a/src/__tests__/renderer/utils/outputSearch.test.ts +++ b/src/__tests__/renderer/utils/outputSearch.test.ts @@ -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'; @@ -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', () => { @@ -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); + }); +}); diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index 09d3040eec..4c1f63e040 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -70,77 +70,19 @@ import { GraphLegend } from './GraphLegend'; import { MarkdownRenderer } from '../MarkdownRenderer'; import { generateProseStyles } from '../../utils/markdownConfig'; import { safeClipboardWrite } from '../../utils/clipboard'; -import type { FileNode } from '../../types/fileTree'; +import { buildFileTreeFromPaths } from '../../utils/fileTree'; +import { countMarkdownTasks } from '../FilePreview/filePreviewUtils'; import { logger } from '../../utils/logger'; import { useSettingsStore } from '../../stores/settingsStore'; /** Debounce delay for graph rebuilds when settings change (ms) */ const GRAPH_REBUILD_DEBOUNCE_DELAY = 300; -/** - * Build a file tree structure from graph node file paths. - * This enables wiki-link resolution in the preview panel. - */ -function buildFileTreeFromPaths(filePaths: string[]): FileNode[] { - const root: FileNode[] = []; - const folderMap = new Map(); - - for (const filePath of filePaths) { - if (!filePath) continue; - - const parts = filePath.split('/'); - let currentLevel = root; - let currentPath = ''; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isLastPart = i === parts.length - 1; - currentPath = currentPath ? `${currentPath}/${part}` : part; - - if (isLastPart) { - // It's a file - currentLevel.push({ - name: part, - type: 'file', - fullPath: filePath, - }); - } else { - // It's a folder - check if it already exists - let folder = folderMap.get(currentPath); - if (!folder) { - folder = { - name: part, - type: 'folder', - isFolder: true, - children: [], - }; - folderMap.set(currentPath, folder); - currentLevel.push(folder); - } - currentLevel = folder.children!; - } - } - } - - return root; -} /** Default maximum number of nodes to load initially */ const DEFAULT_MAX_NODES = 200; /** Number of additional nodes to load when clicking "Load more" */ const LOAD_MORE_INCREMENT = 25; -/** - * Count markdown tasks (checkboxes) in content - * Reuses pattern from FilePreview.tsx - */ -const countMarkdownTasks = (content: string): { completed: number; total: number } => { - const openMatches = content.match(/^[\s]*[-*]\s*\[\s*\]/gm); - const closedMatches = content.match(/^[\s]*[-*]\s*\[[xX]\]/gm); - const open = openMatches?.length || 0; - const closed = closedMatches?.length || 0; - return { completed: closed, total: open + closed }; -}; - /** * Format date for display in footer */ @@ -897,8 +839,9 @@ export function DocumentGraphView({ .readFile(fullPath, sshRemoteId) .then((content) => { if (!content) return; - const tasks = countMarkdownTasks(content); - setSelectedNodeTasks(tasks.total > 0 ? tasks : null); + const { open, closed } = countMarkdownTasks(content); + const total = open + closed; + setSelectedNodeTasks(total > 0 ? { completed: closed, total } : null); }) .catch(() => { setSelectedNodeTasks(null); diff --git a/src/renderer/components/GroupChatPanel.tsx b/src/renderer/components/GroupChatPanel.tsx index a3ea6743f2..695a8da237 100644 --- a/src/renderer/components/GroupChatPanel.tsx +++ b/src/renderer/components/GroupChatPanel.tsx @@ -20,7 +20,7 @@ import { GroupChatHeader } from './GroupChatHeader'; import { GroupChatMessages, type GroupChatMessagesHandle } from './GroupChatMessages'; import { GroupChatInput } from './GroupChatInput'; import { OutputSearchBar } from './TerminalOutput/components/OutputSearchBar'; -import { groupChatOutputSearchKey } from '../utils/outputSearch'; +import { groupChatOutputSearchKey, groupChatSearchContentRevision } from '../utils/outputSearch'; import { useOutputSearchSlot } from '../hooks/ui/useOutputSearchSlot'; import { useOutputSearchLayer } from '../hooks/ui/useOutputSearchLayer'; import { useOutputSearchMatching } from '../hooks/ui/useOutputSearchMatching'; @@ -153,7 +153,11 @@ export function GroupChatPanel({ outputSearchOpen, outputSearchRegex, debouncedSearchQuery, - contentRevision: `${messages.length}:${debouncedSearchQuery}:${outputSearchOpen}`, + contentRevision: groupChatSearchContentRevision( + messages, + debouncedSearchQuery, + outputSearchOpen + ), }); // Leaving this group chat (unmount or switch to another id) must clear the diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 4f615e4e22..3071d5850f 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -458,12 +458,23 @@ export const RightPanel = memo( onClick={() => setActiveFocus('right')} onFocus={() => setActiveFocus('right')} onBlur={(e) => { - // Clear focus ring when focus moves entirely outside this panel - if (!e.currentTarget.contains(e.relatedTarget as Node)) { + const panel = e.currentTarget; + const next = e.relatedTarget as Node | null; + // Focus moved to another node still inside this panel (tab button, + // file row, filter input). Keep the Right Bar focused. + if (next && panel.contains(next)) return; + // relatedTarget is null when the click landed on a non-focusable + // child (padding, file-tree row). That is NOT "left the panel" - + // React fires blur anyway, and treating null as outside handed + // focus to Main on a second click. Check after the click: only + // drop the ring when the caret actually left. + requestAnimationFrame(() => { + if (!panel.isConnected) return; + if (panel.contains(document.activeElement)) return; if (useUIStore.getState().activeFocus === 'right') { setActiveFocus('main'); } - } + }); }} > {/* Resize Handle */} diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index 177307197b..c29c48cc35 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -372,6 +372,14 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { ctx.activeFocus === 'right' && ctx.activeRightTab === 'files' && ctx.fileTreeFilterOpen; + // Cmd+F on a focused side pane is that pane's local filter, even if + // the transcript Find bar is still open from earlier. + const isPaneLocalFindShortcut = + (e.metaKey || e.ctrlKey) && + !e.altKey && + !e.shiftKey && + keyLower === 'f' && + (ctx.activeFocus === 'right' || ctx.activeFocus === 'sidebar'); // The Concerto keys stay live through the guard. The stage is a // workspace surface, not a dialog, so its own toggle has to be able to // close it - a toggle that only ever opens is a dead keypress. And @@ -459,6 +467,7 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { !isBrowserFindShortcut && !isBrowserNavShortcut && !isFileFilterRefocusShortcut && + !isPaneLocalFindShortcut && !isOutputSearchGlobalShortcut && !isOutputSearchRefocusShortcut && !isConcertoToggleShortcut && @@ -512,11 +521,14 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { }; // Cmd+F while the output find bar is already open: bring focus back to its - // input from anywhere instead of no-opping. The find bar's own keydown - // handler only opens search when it's closed, so without this re-pressing - // the shortcut after focus moved away (e.g. to the AI input) does nothing. + // input from anywhere in the MAIN chat, instead of no-opping. Do NOT steal + // the chord when a side pane is focused: the Right Bar (Files/History) and + // Left Bar have their own Cmd+F filters, and a click on those panes is the + // user asking for that filter, not the transcript Find they left open. + const sidePaneOwnsFind = ctx.activeFocus === 'right' || ctx.activeFocus === 'sidebar'; if ( isActiveOutputSearchOpen() && + !sidePaneOwnsFind && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && @@ -1513,12 +1525,15 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { } } - // Cmd+F contextual shortcuts - prioritize explicit focus over input mode - if (e.key === 'f' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + // Cmd+F contextual shortcuts - prioritize explicit focus over input mode. + // Alt is excluded: Opt+Cmd+F is searchAllTabs (cross-tab message search) + // and must not also open in-tab Find, group Find, Files filter, or + // terminal/browser find. + if (e.key === 'f' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Browser-tab in-page find takes precedence whenever a browser tab is // the active tab. Routed both here (when webview isn't focused) and via // `onBrowserTabShortcutKey` (when it is). - if (activeSession?.activeBrowserTabId && !e.altKey) { + if (activeSession?.activeBrowserTabId) { e.preventDefault(); ctx.mainPanelRef?.current?.openBrowserFind(); trackShortcut('searchOutput'); diff --git a/src/renderer/utils/fileTree.ts b/src/renderer/utils/fileTree.ts new file mode 100644 index 0000000000..250668cc1b --- /dev/null +++ b/src/renderer/utils/fileTree.ts @@ -0,0 +1,67 @@ +/** + * File tree construction helpers. + * + * Companion to the `FileNode` shape in `src/renderer/types/fileTree.ts`. + * + * Distinct from `buildTreeFromPaths()` in `fileExplorer.ts`, which takes a + * separate directory list plus a file list and produces `FileTreeNode` for the + * Files panel. This one infers folders from the paths themselves and produces + * `FileNode`, which is what wiki-link resolution consumes. + */ + +import type { FileNode } from '../types/fileTree'; + +/** + * Build a nested `FileNode` tree from a flat list of relative file paths. + * + * Folders are inferred from the path segments rather than supplied separately, + * so a caller that only knows which files exist (the Document Graph knows its + * nodes, not the directories around them) still gets a tree that wiki-link + * resolution can index. + * + * Empty and duplicate-folder paths are tolerated: blank entries are skipped and + * a folder seen twice is reused rather than duplicated. + */ +export function buildFileTreeFromPaths(filePaths: string[]): FileNode[] { + const root: FileNode[] = []; + const folderMap = new Map(); + + for (const filePath of filePaths) { + if (!filePath) continue; + + const parts = filePath.split('/'); + let currentLevel = root; + let currentPath = ''; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLastPart = i === parts.length - 1; + currentPath = currentPath ? `${currentPath}/${part}` : part; + + if (isLastPart) { + // It's a file + currentLevel.push({ + name: part, + type: 'file', + fullPath: filePath, + }); + } else { + // It's a folder - check if it already exists + let folder = folderMap.get(currentPath); + if (!folder) { + folder = { + name: part, + type: 'folder', + isFolder: true, + children: [], + }; + folderMap.set(currentPath, folder); + currentLevel.push(folder); + } + currentLevel = folder.children!; + } + } + } + + return root; +} diff --git a/src/renderer/utils/outputSearch.ts b/src/renderer/utils/outputSearch.ts index 79c78c22af..3bc45219d0 100644 --- a/src/renderer/utils/outputSearch.ts +++ b/src/renderer/utils/outputSearch.ts @@ -24,6 +24,20 @@ export function groupChatOutputSearchKey(groupChatId: string): string { return `group-chat::${groupChatId}`; } +/** + * Fingerprint for group-chat Find. Message COUNT is not enough: a streaming + * reply grows `content` in place, so the Find bar must re-scan when any + * message's text length changes. Query and open are included so a closed bar + * with a leftover query does not share a revision with an open one. + */ +export function groupChatSearchContentRevision( + messages: ReadonlyArray<{ content: string }>, + searchQuery: string, + searchOpen: boolean +): string { + return `${messages.length}:${messages.map((m) => m.content.length).join(',')}:${searchQuery}:${searchOpen}`; +} + /** * Key for the currently active chat window, or null when none. * Prefers the active group chat when one is open (MainPanel is unmounted then).