From c82e776970503b73b8d812d7a7a6dee2f3d84c7d Mon Sep 17 00:00:00 2001 From: Kyle Hanks Date: Tue, 27 Jan 2026 15:24:53 -0800 Subject: [PATCH] refactor(file-editor): use single shared instance across all tabs Previously each tab maintained its own file editor state (open files, recent files, editor settings). Now the file editor uses a single global state shared across all tabs, so files remain open when switching between main app tabs. - Flatten sessions map to single global state in store - Remove sessionId parameters from hook and component props - Simplify store subscription in App.tsx --- frontend/App.tsx | 12 +- .../CommandBlock/StaticTerminalOutput.tsx | 2 +- .../FileEditorSidebarPanel.tsx | 105 +++--- .../components/FilePathLink/FilePathLink.tsx | 5 +- frontend/components/Markdown/Markdown.tsx | 1 - frontend/hooks/useFileEditorSidebar.ts | 139 +++----- frontend/store/file-editor-sidebar.ts | 313 +++++++----------- 7 files changed, 235 insertions(+), 342 deletions(-) diff --git a/frontend/App.tsx b/frontend/App.tsx index eb85f227..0bb544bf 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -97,10 +97,7 @@ function App() { setGitPanelOpen(false); } else { // Sync store state when closing - this prevents the effect from re-opening - const focusedId = focusedSessionIdRef.current; - if (focusedId) { - useFileEditorSidebarStore.getState().setOpen(focusedId, false); - } + useFileEditorSidebarStore.getState().setOpen(false); } setFileEditorPanelOpen(open); }, []); @@ -140,9 +137,7 @@ function App() { // Subscribe to file editor sidebar store to sync open state // This allows openFile() calls from anywhere to open the sidebar - const fileEditorStoreOpen = useFileEditorSidebarStore((state) => - focusedSessionId ? state.sessions[focusedSessionId]?.open : false - ); + const fileEditorStoreOpen = useFileEditorSidebarStore((state) => state.open); useEffect(() => { if (fileEditorStoreOpen && !fileEditorPanelOpen) { handleFileEditorPanelOpenChange(true); @@ -960,9 +955,8 @@ function App() { {/* Context Panel - integrated side panel, uses sidecar's current session */} - {/* File Editor Panel - right side code editor */} + {/* File Editor Panel - right side code editor (shared across all tabs) */} (null); - const { openFile } = useFileEditorSidebar(sessionId ?? null, workingDirectory); + const { openFile } = useFileEditorSidebar(workingDirectory); // Calculate rows needed for content (pre-render estimate) const lineCount = output.split("\n").length; diff --git a/frontend/components/FileEditorSidebar/FileEditorSidebarPanel.tsx b/frontend/components/FileEditorSidebar/FileEditorSidebarPanel.tsx index 9d3beb5c..fe6f1262 100644 --- a/frontend/components/FileEditorSidebar/FileEditorSidebarPanel.tsx +++ b/frontend/components/FileEditorSidebar/FileEditorSidebarPanel.tsx @@ -70,33 +70,30 @@ function registerVimCommands() { const args = params.args || []; const arg = args[0]?.toLowerCase(); - // Get current session ID from the store (find the one with vimMode enabled) const state = useFileEditorSidebarStore.getState(); - const sessionId = Object.keys(state.sessions).find((id) => state.sessions[id]?.vimMode); - if (!sessionId) return; switch (arg) { case "wrap": - state.setWrap(sessionId, true); + state.setWrap(true); break; case "nowrap": - state.setWrap(sessionId, false); + state.setWrap(false); break; case "number": case "nu": - state.setLineNumbers(sessionId, true); + state.setLineNumbers(true); break; case "nonumber": case "nonu": - state.setLineNumbers(sessionId, false); + state.setLineNumbers(false); break; case "relativenumber": case "rnu": - state.setRelativeLineNumbers(sessionId, true); + state.setRelativeLineNumbers(true); break; case "norelativenumber": case "nornu": - state.setRelativeLineNumbers(sessionId, false); + state.setRelativeLineNumbers(false); break; } }); @@ -155,7 +152,6 @@ function registerVimCommands() { } interface FileEditorSidebarPanelProps { - sessionId: string | null; open: boolean; onOpenChange: (open: boolean) => void; workingDirectory?: string | null; @@ -284,16 +280,21 @@ function FileOpenPrompt({ } export function FileEditorSidebarPanel({ - sessionId, open, onOpenChange, workingDirectory, }: FileEditorSidebarPanelProps) { const { - session, + activeTabId, activeTab, activeFile, tabs, + vimMode, + vimModeState, + wrap, + lineNumbers, + recentFiles, + width, openFile, openBrowser, saveFile, @@ -310,7 +311,7 @@ export function FileEditorSidebarPanel({ setVimModeState, toggleMarkdownPreview, reorderTabs, - } = useFileEditorSidebar(sessionId, workingDirectory || undefined); + } = useFileEditorSidebar(workingDirectory || undefined); const [containerWidth, setContainerWidth] = useState(DEFAULT_WIDTH); const isResizing = useRef(false); @@ -319,32 +320,32 @@ export function FileEditorSidebarPanel({ // Navigate to next/previous tab const goToNextTab = useCallback(() => { - if (!session || tabs.length <= 1) return; - const currentIndex = session.activeTabId ? session.tabOrder.indexOf(session.activeTabId) : -1; + if (tabs.length <= 1) return; + const tabOrder = tabs.map((tab) => tab.id); + const currentIndex = activeTabId ? tabOrder.indexOf(activeTabId) : -1; const nextIndex = (currentIndex + 1) % tabs.length; - const nextId = session.tabOrder[nextIndex]; + const nextId = tabOrder[nextIndex]; if (nextId) setActiveTab(nextId); - }, [session, tabs.length, setActiveTab]); + }, [activeTabId, tabs, setActiveTab]); const goToPrevTab = useCallback(() => { - if (!session || tabs.length <= 1) return; - const currentIndex = session.activeTabId ? session.tabOrder.indexOf(session.activeTabId) : -1; + if (tabs.length <= 1) return; + const tabOrder = tabs.map((tab) => tab.id); + const currentIndex = activeTabId ? tabOrder.indexOf(activeTabId) : -1; const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length; - const prevId = session.tabOrder[prevIndex]; + const prevId = tabOrder[prevIndex]; if (prevId) setActiveTab(prevId); - }, [session, tabs.length, setActiveTab]); + }, [activeTabId, tabs, setActiveTab]); useEffect(() => { - if (session?.width) { - setContainerWidth(session.width); + if (width) { + setContainerWidth(width); } - }, [session?.width]); + }, [width]); useEffect(() => { - if (sessionId) { - setOpen(open); - } - }, [open, setOpen, sessionId]); + setOpen(open); + }, [open, setOpen]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { @@ -352,7 +353,7 @@ export function FileEditorSidebarPanel({ const newWidth = window.innerWidth - e.clientX; if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) { setContainerWidth(newWidth); - if (sessionId) setWidth(newWidth); + setWidth(newWidth); } }; @@ -371,7 +372,7 @@ export function FileEditorSidebarPanel({ document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [setWidth, sessionId]); + }, [setWidth]); const onStartResize = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -382,7 +383,7 @@ export function FileEditorSidebarPanel({ // Register custom vim commands when vim mode is first enabled useEffect(() => { - if (session?.vimMode) { + if (vimMode) { registerVimCommands(); setVimCallbacks({ save: () => void saveFile(), @@ -391,16 +392,14 @@ export function FileEditorSidebarPanel({ closeTab(); // Check if we still have tabs after closing const state = useFileEditorSidebarStore.getState(); - const currentSession = sessionId ? state.sessions[sessionId] : null; - if (!currentSession || currentSession.tabOrder.length === 0) { + if (state.tabOrder.length === 0) { onOpenChange(false); } }, forceClose: () => { closeTab(); const state = useFileEditorSidebarStore.getState(); - const currentSession = sessionId ? state.sessions[sessionId] : null; - if (!currentSession || currentSession.tabOrder.length === 0) { + if (state.tabOrder.length === 0) { onOpenChange(false); } }, @@ -435,19 +434,18 @@ export function FileEditorSidebarPanel({ }); }; }, [ - session?.vimMode, + vimMode, saveFile, reloadFile, closeTab, closeAllTabs, onOpenChange, - sessionId, goToNextTab, goToPrevTab, ]); useEffect(() => { - if (!session?.vimMode || !editorRef.current?.view) return; + if (!vimMode || !editorRef.current?.view) return; // biome-ignore lint/suspicious/noExplicitAny: CodeMirror vim internals not fully typed const cm = (editorRef.current.view as any).cm; @@ -465,17 +463,17 @@ export function FileEditorSidebarPanel({ return () => { cm.off("vim-mode-change", handler); }; - }, [session?.vimMode, setVimModeState]); + }, [vimMode, setVimModeState]); const extensions = useMemo(() => { const ext: Extension[] = []; const lang = languageExtension(activeFile?.language); if (lang) ext.push(lang); - if (session?.wrap) { + if (wrap) { ext.push(EditorView.lineWrapping); } - if (session?.vimMode) { + if (vimMode) { ext.push(vim()); } // Keymap for save shortcut inside the editor @@ -493,19 +491,19 @@ export function FileEditorSidebarPanel({ ); return ext; - }, [saveFile, activeFile?.language, session?.vimMode, session?.wrap]); + }, [saveFile, activeFile?.language, vimMode, wrap]); // Memoize basicSetup to react to line number settings changes const basicSetup = useMemo( () => ({ - lineNumbers: session?.lineNumbers ?? true, + lineNumbers: lineNumbers ?? true, foldGutter: true, highlightActiveLine: true, }), - [session?.lineNumbers] + [lineNumbers] ); - if (!open || !sessionId) return null; + if (!open) return null; const hasTabs = tabs.length > 0; @@ -517,7 +515,7 @@ export function FileEditorSidebarPanel({ workingDirectory={workingDirectory ?? undefined} onOpen={(path) => openFile(path)} onOpenBrowser={() => openBrowser()} - recentFiles={session?.recentFiles ?? []} + recentFiles={recentFiles} /> ); } @@ -651,14 +649,13 @@ export function FileEditorSidebarPanel({ {hasTabs && ( { closeTab(tabId); // If no tabs left, close panel const state = useFileEditorSidebarStore.getState(); - const currentSession = sessionId ? state.sessions[sessionId] : null; - if (!currentSession || currentSession.tabOrder.length === 0) { + if (state.tabOrder.length === 0) { onOpenChange(false); } }} @@ -677,9 +674,9 @@ export function FileEditorSidebarPanel({ {/* Footer */}
- {session?.vimMode && activeTab?.type === "file" && ( + {vimMode && activeTab?.type === "file" && ( - {session?.vimModeState ?? "normal"} + {vimModeState ?? "normal"} )} {activeTab?.type === "browser" && ( @@ -692,14 +689,14 @@ export function FileEditorSidebarPanel({ {activeTab?.type === "file" && ( diff --git a/frontend/components/FilePathLink/FilePathLink.tsx b/frontend/components/FilePathLink/FilePathLink.tsx index 94da5bba..7e032325 100644 --- a/frontend/components/FilePathLink/FilePathLink.tsx +++ b/frontend/components/FilePathLink/FilePathLink.tsx @@ -16,8 +16,6 @@ interface FilePathLinkProps { detected: DetectedPath; /** Working directory for path resolution */ workingDirectory: string; - /** Session ID for file editor */ - sessionId: string; /** The text to display (may differ from detected.raw if we only wrap part of it) */ children: ReactNode; /** Pre-resolved absolute path (if known from index) */ @@ -27,7 +25,6 @@ interface FilePathLinkProps { export function FilePathLink({ detected, workingDirectory, - sessionId, children, absolutePath, }: FilePathLinkProps) { @@ -35,7 +32,7 @@ export function FilePathLink({ const [loading, setLoading] = useState(false); const [resolvedPaths, setResolvedPaths] = useState([]); - const { openFile } = useFileEditorSidebar(sessionId, workingDirectory); + const { openFile } = useFileEditorSidebar(workingDirectory); const handleClick = useCallback(async () => { if (open) { diff --git a/frontend/components/Markdown/Markdown.tsx b/frontend/components/Markdown/Markdown.tsx index 181f6fbc..b5185136 100644 --- a/frontend/components/Markdown/Markdown.tsx +++ b/frontend/components/Markdown/Markdown.tsx @@ -76,7 +76,6 @@ function processTextWithFilePaths(text: string, context: MarkdownContextValue): diff --git a/frontend/hooks/useFileEditorSidebar.ts b/frontend/hooks/useFileEditorSidebar.ts index 986a6dfe..3c50b965 100644 --- a/frontend/hooks/useFileEditorSidebar.ts +++ b/frontend/hooks/useFileEditorSidebar.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { readWorkspaceFile, writeWorkspaceFile } from "@/lib/file-editor"; import { notify } from "@/lib/notify"; import { @@ -6,7 +6,6 @@ import { fileTabIdFromPath, selectActiveFileTab, selectActiveTab, - selectSessionState, type Tab, useFileEditorSidebarStore, } from "@/store/file-editor-sidebar"; @@ -53,109 +52,80 @@ function detectLanguageFromPath(path: string): string | undefined { return undefined; } -export function useFileEditorSidebar(sessionId: string | null, workingDirectory?: string) { - useEffect(() => { - if (sessionId) { - useFileEditorSidebarStore.getState().ensureSession(sessionId); - } - }, [sessionId]); +export function useFileEditorSidebar(workingDirectory?: string) { + // Subscribe to the global store state + const state = useFileEditorSidebarStore(); - const session = useFileEditorSidebarStore( - useCallback((state) => (sessionId ? selectSessionState(state, sessionId) : null), [sessionId]) - ); - - // Derive active tab and active file from session - const activeTab = useMemo(() => (session ? selectActiveTab(session) : null), [session]); - const activeFileTab = useMemo(() => (session ? selectActiveFileTab(session) : null), [session]); + // Derive active tab and active file from state + const activeTab = useMemo(() => selectActiveTab(state), [state]); + const activeFileTab = useMemo(() => selectActiveFileTab(state), [state]); const activeFile = activeFileTab?.file ?? null; const actions = useMemo(() => { return { setOpen: (open: boolean) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setOpen(sessionId, open); + useFileEditorSidebarStore.getState().setOpen(open); }, setWidth: (width: number) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setWidth(sessionId, width); + useFileEditorSidebarStore.getState().setWidth(width); }, setStatus: (status?: string) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setStatus(sessionId, status); + useFileEditorSidebarStore.getState().setStatus(status); }, setActiveTab: (tabId: string) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setActiveTab(sessionId, tabId); + useFileEditorSidebarStore.getState().setActiveTab(tabId); }, closeTab: (tabId?: string) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().closeTab(sessionId, tabId); + useFileEditorSidebarStore.getState().closeTab(tabId); }, closeAllTabs: () => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().closeAllTabs(sessionId); + useFileEditorSidebarStore.getState().closeAllTabs(); }, closeOtherTabs: (keepTabId: string) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().closeOtherTabs(sessionId, keepTabId); + useFileEditorSidebarStore.getState().closeOtherTabs(keepTabId); }, reorderTabs: (fromIndex: number, toIndex: number) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().reorderTabs(sessionId, fromIndex, toIndex); + useFileEditorSidebarStore.getState().reorderTabs(fromIndex, toIndex); }, updateFileContent: (tabId: string, content: string) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().updateFileContent(sessionId, tabId, content); + useFileEditorSidebarStore.getState().updateFileContent(tabId, content); }, setBrowserPath: (tabId: string, path: string) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setBrowserPath(sessionId, tabId, path); + useFileEditorSidebarStore.getState().setBrowserPath(tabId, path); }, setVimMode: (enabled: boolean) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setVimMode(sessionId, enabled); + useFileEditorSidebarStore.getState().setVimMode(enabled); }, setVimModeState: (state: "normal" | "insert" | "visual") => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setVimModeState(sessionId, state); + useFileEditorSidebarStore.getState().setVimModeState(state); }, setWrap: (enabled: boolean) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setWrap(sessionId, enabled); + useFileEditorSidebarStore.getState().setWrap(enabled); }, setLineNumbers: (enabled: boolean) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setLineNumbers(sessionId, enabled); + useFileEditorSidebarStore.getState().setLineNumbers(enabled); }, setRelativeLineNumbers: (enabled: boolean) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().setRelativeLineNumbers(sessionId, enabled); + useFileEditorSidebarStore.getState().setRelativeLineNumbers(enabled); }, addRecentFile: (path: string) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().addRecentFile(sessionId, path); + useFileEditorSidebarStore.getState().addRecentFile(path); }, toggleMarkdownPreview: (tabId: string) => { - if (!sessionId) return; - useFileEditorSidebarStore.getState().toggleMarkdownPreview(sessionId, tabId); + useFileEditorSidebarStore.getState().toggleMarkdownPreview(tabId); }, }; - }, [sessionId]); + }, []); const openFile = useCallback( async (inputPath: string) => { - if (!sessionId) { - notify.error("No active session for file open"); - return; - } const fullPath = resolvePath(inputPath, workingDirectory); // If file is already open, just switch to it const tabId = fileTabIdFromPath(fullPath); - const state = useFileEditorSidebarStore.getState(); - const currentSession = state.sessions[sessionId]; - if (currentSession?.tabs[tabId]) { - state.setActiveTab(sessionId, tabId); + const currentState = useFileEditorSidebarStore.getState(); + if (currentState.tabs[tabId]) { + currentState.setActiveTab(tabId); return; } @@ -172,7 +142,7 @@ export function useFileEditorSidebar(sessionId: string | null, workingDirectory? lastReadAt: new Date().toISOString(), lastSavedAt: result.modifiedAt, }; - useFileEditorSidebarStore.getState().openFileTab(sessionId, file); + useFileEditorSidebarStore.getState().openFileTab(file); actions.addRecentFile(fullPath); } catch (error) { notify.error(`Failed to open file: ${error}`); @@ -180,32 +150,27 @@ export function useFileEditorSidebar(sessionId: string | null, workingDirectory? actions.setStatus(undefined); } }, - [actions, sessionId, workingDirectory] + [actions, workingDirectory] ); const openBrowser = useCallback( (initialPath?: string) => { - if (!sessionId) { - notify.error("No active session"); - return; - } const path = initialPath ?? workingDirectory ?? ""; - useFileEditorSidebarStore.getState().openBrowserTab(sessionId, path); + useFileEditorSidebarStore.getState().openBrowserTab(path); }, - [sessionId, workingDirectory] + [workingDirectory] ); const saveFile = useCallback( async (tabId?: string) => { - if (!sessionId || !session) return; - - const targetTabId = tabId ?? session.activeTabId; + const currentState = useFileEditorSidebarStore.getState(); + const targetTabId = tabId ?? currentState.activeTabId; if (!targetTabId) { notify.info("No file open to save"); return; } - const tab = session.tabs[targetTabId]; + const tab = currentState.tabs[targetTabId]; if (!tab || tab.type !== "file") { notify.info("Current tab is not a file"); return; @@ -217,9 +182,7 @@ export function useFileEditorSidebar(sessionId: string | null, workingDirectory? const result = await writeWorkspaceFile(file.path, file.content, { expectedModifiedAt: file.lastSavedAt, }); - useFileEditorSidebarStore - .getState() - .markFileSaved(sessionId, targetTabId, result.modifiedAt); + useFileEditorSidebarStore.getState().markFileSaved(targetTabId, result.modifiedAt); notify.success("Saved"); } catch (error) { notify.error(`Failed to save file: ${error}`); @@ -227,20 +190,19 @@ export function useFileEditorSidebar(sessionId: string | null, workingDirectory? actions.setStatus(undefined); } }, - [actions, session, sessionId] + [actions] ); const reloadFile = useCallback( async (tabId?: string) => { - if (!sessionId || !session) return; - - const targetTabId = tabId ?? session.activeTabId; + const currentState = useFileEditorSidebarStore.getState(); + const targetTabId = tabId ?? currentState.activeTabId; if (!targetTabId) { notify.info("No file open to reload"); return; } - const tab = session.tabs[targetTabId]; + const tab = currentState.tabs[targetTabId]; if (!tab || tab.type !== "file") { notify.info("Current tab is not a file"); return; @@ -258,24 +220,33 @@ export function useFileEditorSidebar(sessionId: string | null, workingDirectory? lastReadAt: new Date().toISOString(), lastSavedAt: result.modifiedAt, }; - useFileEditorSidebarStore.getState().openFileTab(sessionId, newFile); + useFileEditorSidebarStore.getState().openFileTab(newFile); } catch (error) { notify.error(`Failed to reload file: ${error}`); } finally { actions.setStatus(undefined); } }, - [actions, session, sessionId] + [actions] ); // Get tabs as array for rendering const tabs = useMemo((): Tab[] => { - if (!session) return []; - return session.tabOrder.map((id) => session.tabs[id]).filter((t): t is Tab => t !== undefined); - }, [session]); + return state.tabOrder.map((id) => state.tabs[id]).filter((t): t is Tab => t !== undefined); + }, [state.tabOrder, state.tabs]); return { - session, + // State (entire store state for convenience, but components should use specific fields) + open: state.open, + width: state.width, + vimMode: state.vimMode, + vimModeState: state.vimModeState, + wrap: state.wrap, + lineNumbers: state.lineNumbers, + relativeLineNumbers: state.relativeLineNumbers, + recentFiles: state.recentFiles, + status: state.status, + activeTabId: state.activeTabId, activeTab, activeFileTab, activeFile, diff --git a/frontend/store/file-editor-sidebar.ts b/frontend/store/file-editor-sidebar.ts index ce19728b..90b2a3f2 100644 --- a/frontend/store/file-editor-sidebar.ts +++ b/frontend/store/file-editor-sidebar.ts @@ -38,7 +38,8 @@ export interface BrowserTab extends BaseTab { export type Tab = FileTab | BrowserTab; -export interface FileEditorSessionState { +// Single global state (no longer per-session) +interface FileEditorSidebarState { open: boolean; width: number; // Tab-based model @@ -54,57 +55,37 @@ export interface FileEditorSessionState { lineNumbers: boolean; relativeLineNumbers: boolean; status?: string; -} -interface FileEditorSidebarState { - sessions: Record; - ensureSession: (sessionId: string) => FileEditorSessionState; - setOpen: (sessionId: string, open: boolean) => void; - setWidth: (sessionId: string, width: number) => void; - setStatus: (sessionId: string, status?: string) => void; + // Actions + setOpen: (open: boolean) => void; + setWidth: (width: number) => void; + setStatus: (status?: string) => void; // Tab operations - openFileTab: (sessionId: string, file: EditorFileState) => void; - openBrowserTab: (sessionId: string, initialPath?: string) => void; - setActiveTab: (sessionId: string, tabId: string) => void; - closeTab: (sessionId: string, tabId?: string) => void; - closeAllTabs: (sessionId: string) => void; - closeOtherTabs: (sessionId: string, keepTabId: string) => void; - reorderTabs: (sessionId: string, fromIndex: number, toIndex: number) => void; + openFileTab: (file: EditorFileState) => void; + openBrowserTab: (initialPath?: string) => void; + setActiveTab: (tabId: string) => void; + closeTab: (tabId?: string) => void; + closeAllTabs: () => void; + closeOtherTabs: (keepTabId: string) => void; + reorderTabs: (fromIndex: number, toIndex: number) => void; // File tab specific - updateFileContent: (sessionId: string, tabId: string, content: string) => void; - markFileSaved: (sessionId: string, tabId: string, timestamp?: string) => void; - toggleMarkdownPreview: (sessionId: string, tabId: string) => void; + updateFileContent: (tabId: string, content: string) => void; + markFileSaved: (tabId: string, timestamp?: string) => void; + toggleMarkdownPreview: (tabId: string) => void; // Browser tab specific - setBrowserPath: (sessionId: string, tabId: string, path: string) => void; + setBrowserPath: (tabId: string, path: string) => void; // Editor settings - setVimMode: (sessionId: string, enabled: boolean) => void; - setVimModeState: (sessionId: string, state: "normal" | "insert" | "visual") => void; - setWrap: (sessionId: string, enabled: boolean) => void; - setLineNumbers: (sessionId: string, enabled: boolean) => void; - setRelativeLineNumbers: (sessionId: string, enabled: boolean) => void; - addRecentFile: (sessionId: string, path: string) => void; - resetSession: (sessionId: string) => void; + setVimMode: (enabled: boolean) => void; + setVimModeState: (state: "normal" | "insert" | "visual") => void; + setWrap: (enabled: boolean) => void; + setLineNumbers: (enabled: boolean) => void; + setRelativeLineNumbers: (enabled: boolean) => void; + addRecentFile: (path: string) => void; + reset: () => void; } const DEFAULT_WIDTH = 420; -function createDefaultSessionState(): FileEditorSessionState { - return { - open: false, - width: DEFAULT_WIDTH, - tabs: {}, - activeTabId: null, - tabOrder: [], - recentFiles: [], - vimMode: true, - vimModeState: "normal", - wrap: false, - lineNumbers: true, - relativeLineNumbers: false, - status: undefined, - }; -} - // Generate unique tab IDs let tabIdCounter = 0; function generateTabId(type: TabType): string { @@ -116,50 +97,56 @@ function getFileTabId(path: string): string { return `file:${path}`; } +// Export for use in hook +export function fileTabIdFromPath(path: string): string { + return getFileTabId(path); +} + +const initialState = { + open: false, + width: DEFAULT_WIDTH, + tabs: {} as Record, + activeTabId: null as string | null, + tabOrder: [] as string[], + recentFiles: [] as string[], + vimMode: true, + vimModeState: "normal" as const, + wrap: false, + lineNumbers: true, + relativeLineNumbers: false, + status: undefined as string | undefined, +}; + export const useFileEditorSidebarStore = create()( - immer((set, get) => ({ - sessions: {}, - - ensureSession: (sessionId) => { - const state = get(); - if (!state.sessions[sessionId]) { - set((draft) => { - draft.sessions[sessionId] = createDefaultSessionState(); - }); - } - return get().sessions[sessionId] ?? createDefaultSessionState(); - }, + immer((set) => ({ + ...initialState, - setOpen: (sessionId, open) => { + setOpen: (open) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - draft.sessions[sessionId] = { ...session, open }; + draft.open = open; }); }, - setWidth: (sessionId, width) => { + setWidth: (width) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - draft.sessions[sessionId] = { ...session, width }; + draft.width = width; }); }, - setStatus: (sessionId, status) => { + setStatus: (status) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - draft.sessions[sessionId] = { ...session, status }; + draft.status = status; }); }, - openFileTab: (sessionId, file) => { + openFileTab: (file) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); const tabId = getFileTabId(file.path); // Check if file is already open - if (session.tabs[tabId]) { + if (draft.tabs[tabId]) { // Just switch to it - session.activeTabId = tabId; + draft.activeTabId = tabId; } else { // Create new file tab const tab: FileTab = { @@ -167,25 +154,22 @@ export const useFileEditorSidebarStore = create()( type: "file", file, }; - session.tabs[tabId] = tab; - session.tabOrder.push(tabId); - session.activeTabId = tabId; + draft.tabs[tabId] = tab; + draft.tabOrder.push(tabId); + draft.activeTabId = tabId; } // Auto-open sidebar and add to recent - session.open = true; - session.recentFiles = [ - file.path, - ...session.recentFiles.filter((p) => p !== file.path), - ].slice(0, 10); - - draft.sessions[sessionId] = session; + draft.open = true; + draft.recentFiles = [file.path, ...draft.recentFiles.filter((p) => p !== file.path)].slice( + 0, + 10 + ); }); }, - openBrowserTab: (sessionId, initialPath) => { + openBrowserTab: (initialPath) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); const tabId = generateTabId("browser"); const tab: BrowserTab = { @@ -196,105 +180,88 @@ export const useFileEditorSidebarStore = create()( }, }; - session.tabs[tabId] = tab; - session.tabOrder.push(tabId); - session.activeTabId = tabId; - session.open = true; - - draft.sessions[sessionId] = session; + draft.tabs[tabId] = tab; + draft.tabOrder.push(tabId); + draft.activeTabId = tabId; + draft.open = true; }); }, - setActiveTab: (sessionId, tabId) => { + setActiveTab: (tabId) => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - if (session.tabs[tabId]) { - session.activeTabId = tabId; + if (draft.tabs[tabId]) { + draft.activeTabId = tabId; } }); }, - closeTab: (sessionId, tabId) => { + closeTab: (tabId) => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - - const targetId = tabId ?? session.activeTabId; + const targetId = tabId ?? draft.activeTabId; if (!targetId) return; // Remove tab - delete session.tabs[targetId]; + delete draft.tabs[targetId]; // Remove from order - const tabIndex = session.tabOrder.indexOf(targetId); + const tabIndex = draft.tabOrder.indexOf(targetId); if (tabIndex !== -1) { - session.tabOrder.splice(tabIndex, 1); + draft.tabOrder.splice(tabIndex, 1); } // Update active tab - if (session.activeTabId === targetId) { - if (session.tabOrder.length === 0) { - session.activeTabId = null; + if (draft.activeTabId === targetId) { + if (draft.tabOrder.length === 0) { + draft.activeTabId = null; } else { - const newIndex = Math.min(tabIndex, session.tabOrder.length - 1); - session.activeTabId = session.tabOrder[newIndex] ?? null; + const newIndex = Math.min(tabIndex, draft.tabOrder.length - 1); + draft.activeTabId = draft.tabOrder[newIndex] ?? null; } } }); }, - closeAllTabs: (sessionId) => { + closeAllTabs: () => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - session.tabs = {}; - session.tabOrder = []; - session.activeTabId = null; + draft.tabs = {}; + draft.tabOrder = []; + draft.activeTabId = null; }); }, - closeOtherTabs: (sessionId, keepTabId) => { + closeOtherTabs: (keepTabId) => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - const tabToKeep = session.tabs[keepTabId]; + const tabToKeep = draft.tabs[keepTabId]; if (!tabToKeep) return; - session.tabs = { [keepTabId]: tabToKeep }; - session.tabOrder = [keepTabId]; - session.activeTabId = keepTabId; + draft.tabs = { [keepTabId]: tabToKeep }; + draft.tabOrder = [keepTabId]; + draft.activeTabId = keepTabId; }); }, - reorderTabs: (sessionId, fromIndex, toIndex) => { + reorderTabs: (fromIndex, toIndex) => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - if (fromIndex < 0 || fromIndex >= session.tabOrder.length) return; - if (toIndex < 0 || toIndex >= session.tabOrder.length) return; - const [removed] = session.tabOrder.splice(fromIndex, 1); + if (fromIndex < 0 || fromIndex >= draft.tabOrder.length) return; + if (toIndex < 0 || toIndex >= draft.tabOrder.length) return; + const [removed] = draft.tabOrder.splice(fromIndex, 1); if (removed) { - session.tabOrder.splice(toIndex, 0, removed); + draft.tabOrder.splice(toIndex, 0, removed); } }); }, - updateFileContent: (sessionId, tabId, content) => { + updateFileContent: (tabId, content) => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - const tab = session.tabs[tabId]; + const tab = draft.tabs[tabId]; if (!tab || tab.type !== "file") return; tab.file.content = content; tab.file.dirty = content !== tab.file.originalContent; }); }, - markFileSaved: (sessionId, tabId, timestamp) => { + markFileSaved: (tabId, timestamp) => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - const tab = session.tabs[tabId]; + const tab = draft.tabs[tabId]; if (!tab || tab.type !== "file") return; tab.file.dirty = false; tab.file.originalContent = tab.file.content; @@ -302,102 +269,75 @@ export const useFileEditorSidebarStore = create()( }); }, - toggleMarkdownPreview: (sessionId, tabId) => { + toggleMarkdownPreview: (tabId) => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - const tab = session.tabs[tabId]; + const tab = draft.tabs[tabId]; if (!tab || tab.type !== "file") return; if (tab.file.language !== "markdown") return; tab.file.markdownPreview = !tab.file.markdownPreview; }); }, - setBrowserPath: (sessionId, tabId, path) => { + setBrowserPath: (tabId, path) => { set((draft) => { - const session = draft.sessions[sessionId]; - if (!session) return; - const tab = session.tabs[tabId]; + const tab = draft.tabs[tabId]; if (!tab || tab.type !== "browser") return; tab.browser.currentPath = path; }); }, - setVimMode: (sessionId, enabled) => { + setVimMode: (enabled) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - draft.sessions[sessionId] = { ...session, vimMode: enabled }; + draft.vimMode = enabled; }); }, - setVimModeState: (sessionId, state) => { + setVimModeState: (state) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - draft.sessions[sessionId] = { ...session, vimModeState: state }; + draft.vimModeState = state; }); }, - setWrap: (sessionId, enabled) => { + setWrap: (enabled) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - draft.sessions[sessionId] = { ...session, wrap: enabled }; + draft.wrap = enabled; }); }, - setLineNumbers: (sessionId, enabled) => { + setLineNumbers: (enabled) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - draft.sessions[sessionId] = { ...session, lineNumbers: enabled }; + draft.lineNumbers = enabled; }); }, - setRelativeLineNumbers: (sessionId, enabled) => { + setRelativeLineNumbers: (enabled) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - draft.sessions[sessionId] = { ...session, relativeLineNumbers: enabled }; + draft.relativeLineNumbers = enabled; }); }, - addRecentFile: (sessionId, path) => { + addRecentFile: (path) => { set((draft) => { - const session = draft.sessions[sessionId] ?? createDefaultSessionState(); - const existing = session.recentFiles.filter((p) => p !== path); - draft.sessions[sessionId] = { - ...session, - recentFiles: [path, ...existing].slice(0, 10), - }; + const existing = draft.recentFiles.filter((p) => p !== path); + draft.recentFiles = [path, ...existing].slice(0, 10); }); }, - resetSession: (sessionId) => { - set((draft) => { - draft.sessions[sessionId] = createDefaultSessionState(); - }); + reset: () => { + set(() => ({ ...initialState })); }, })) ); -// Cached default state to avoid creating new objects on every selector call -const DEFAULT_SESSION_STATE: FileEditorSessionState = Object.freeze({ - ...createDefaultSessionState(), - tabs: Object.freeze({}) as unknown as Record, - tabOrder: Object.freeze([]) as unknown as string[], - recentFiles: Object.freeze([]) as unknown as string[], -}); - -export function selectSessionState(state: FileEditorSidebarState, sessionId: string) { - return state.sessions[sessionId] ?? DEFAULT_SESSION_STATE; -} - -// Helper to get active tab from session -export function selectActiveTab(session: FileEditorSessionState): Tab | null { - if (!session.activeTabId) return null; - return session.tabs[session.activeTabId] ?? null; +// Helper to get active tab +export function selectActiveTab(state: FileEditorSidebarState): Tab | null { + if (!state.activeTabId) return null; + return state.tabs[state.activeTabId] ?? null; } // Helper to get active file tab (if active tab is a file) -export function selectActiveFileTab(session: FileEditorSessionState): FileTab | null { - const tab = selectActiveTab(session); +export function selectActiveFileTab(state: FileEditorSidebarState): FileTab | null { + const tab = selectActiveTab(state); if (!tab || tab.type !== "file") return null; return tab; } @@ -420,8 +360,3 @@ export function getTabDisplayName(tab: Tab): string { } return "Unknown"; } - -// Helper to generate stable file tab ID from path -export function fileTabIdFromPath(path: string): string { - return `file:${path}`; -}