diff --git a/bun.lock b/bun.lock index adc087da..8c911f5e 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", + "@zumer/snapdom": "^2.12.8", "bun-pty": "^0.4.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -98,7 +99,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.12.1", + "version": "1.12.3", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -113,7 +114,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.12.1", + "version": "1.12.3", "dependencies": { "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", @@ -172,6 +173,7 @@ "qrcode": "^1.5.4", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-drawio": "1.0.7", "react-syntax-highlighter": "^15.6.6", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", @@ -213,7 +215,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.12.1", + "version": "1.12.3", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.16.0", @@ -236,7 +238,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.12.1", + "version": "1.12.3", "bin": { "openchamber": "./bin/cli.js", }, @@ -1408,7 +1410,7 @@ "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], - "@zumer/snapdom": ["@zumer/snapdom@2.12.0", "", {}, "sha512-TdLGu+1RkKI3JKfMFvn1gRPD/rl8hrAeN6aFjjd0w7S39nulbd94ChxSlfH7nSCm8kzuQkeWFxIsij4+Mk1RDg=="], + "@zumer/snapdom": ["@zumer/snapdom@2.12.8", "", {}, "sha512-dLX6ZMNjLveasn9yhcruOOfd8GBZBDp59F7iJoLlGf7BnGp0vfVsjxIZDIjN2UTZJN1KoJR/BXsxEnAyY7LuXA=="], "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], @@ -2750,6 +2752,8 @@ "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-drawio": ["react-drawio@1.0.7", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vl3Bk7hLOl3k2i317CtxlG8axqBsLJMsuY6Sw5sT1wpvaT48L7o0JZQ1TW65+YF1GA1PuYeI3W2xpSggcB//wg=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -3376,6 +3380,8 @@ "@npmcli/agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "@openchamber/ui/@zumer/snapdom": ["@zumer/snapdom@2.12.0", "", {}, "sha512-TdLGu+1RkKI3JKfMFvn1gRPD/rl8hrAeN6aFjjd0w7S39nulbd94ChxSlfH7nSCm8kzuQkeWFxIsij4+Mk1RDg=="], + "@openchamber/ui/ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/package.json b/package.json index 800d18c5..25ad14d1 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", + "@zumer/snapdom": "^2.12.8", "bun-pty": "^0.4.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/ui/package.json b/packages/ui/package.json index 6ed832e5..833c99ee 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -68,6 +68,7 @@ "qrcode": "^1.5.4", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-drawio": "1.0.7", "react-syntax-highlighter": "^15.6.6", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 0c8f9e54..d0f8b4de 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -5,6 +5,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { openExternalUrl } from '@/lib/url'; +import { isDrawioFile } from '@/lib/toolHelpers'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; @@ -551,6 +552,7 @@ interface FilePart { url?: string; filename?: string; size?: number; + source?: Record; } const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link'; @@ -573,6 +575,7 @@ interface MessageFilesDisplayProps { } export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => { + const { t } = useI18n(); const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url)); @@ -817,6 +820,41 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } ); } + const source = file.source; + const sourceType = typeof source?.type === 'string' ? source.type : undefined; + const sourcePath = source && typeof (source as Record).path === 'string' ? (source as Record).path as string : undefined; + const filePath = sourceType === 'file' && sourcePath ? sourcePath : (file.url || ''); + const isDrawio = filePath && isDrawioFile(filePath); + + if (isDrawio) { + return ( + + + + + +

{t('chat.fileAttachment.openInDiagram')}

+
+
+ ); + } + return ( diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.test.ts b/packages/ui/src/components/chat/message/parts/ToolPart.test.ts new file mode 100644 index 00000000..36c4d4ed --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/ToolPart.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test'; + +import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser'; + +describe('readTaskTagSessionIdFromOutput', () => { + test('parses task tags without state attributes', () => { + expect(readTaskTagSessionIdFromOutput('')).toBe('ses_abc123'); + }); + + test('parses task tags with additional attributes', () => { + expect(readTaskTagSessionIdFromOutput('')).toBe('ses_def456'); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 4359ae7e..3c90ca68 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -45,6 +45,7 @@ import { ToolRevealOnMount } from './ToolRevealOnMount'; import { getToolIcon } from './toolPresentation'; import { useDurationTickerNow } from './useDurationTicker'; import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId'; +import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; import { getDiffPatchEntries, getPatchText } from './toolDiffUtils'; @@ -957,7 +958,16 @@ const readTaskSessionIdFromOutput = (output: string | undefined): string | undef const taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i); const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i); const candidate = taskMatch?.[1] ?? sessionMatch?.[1]; - return normalizeSessionIdCandidate(candidate); + if (candidate) { + return normalizeSessionIdCandidate(candidate); + } + + // OpenCode tool output may wrap child session id in + const taskTagSessionId = readTaskTagSessionIdFromOutput(output); + if (taskTagSessionId) { + return normalizeSessionIdCandidate(taskTagSessionId); + } + return undefined; }; const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => { diff --git a/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts b/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts index 436e9ee8..897747ad 100644 --- a/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts +++ b/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts @@ -1,103 +1,122 @@ -/** - * resolveFallbackTaskSessionId — pure helper that resolves a pending task tool - * to a child session from the directory session store when explicit taskSessionId - * metadata is delayed. - * - * Conservative: only returns a session id when the match is unambiguous. - */ - -import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client'; - -/** - * Fallback is intentionally narrow: only sessions created shortly after the - * task started are eligible. This avoids binding to earlier or later sibling - * subagent sessions when explicit task metadata is delayed. - */ -/** - * Narrow initial window avoids binding to wrong sessions on first attempt. - * Wide window on retry handles late-appearing child sessions under load. - */ -const TASK_SESSION_MATCH_WINDOW_MS = 3000; -const TASK_SESSION_MATCH_WINDOW_WIDE_MS = 8000; - -const LIVE_STATUSES = new Set(['busy', 'retry']); - -export interface ResolveFallbackParams { - /** True when this tool is a task tool */ - isTaskTool: boolean; - /** The parent session id (current session) */ - parentSessionId: string | undefined; - /** When the task tool started (ms timestamp) */ - taskStartTime: number | undefined; - /** True when the task tool is finalized (completed/error/etc.) */ - isTaskFinalized?: boolean; - /** Sessions from the directory store */ - sessions: Session[]; - /** Session status map from the sync store */ - sessionStatusMap?: Record; - /** True when a previous resolution attempt has already failed (enables wider window) */ - hasRetried?: boolean; -} - -/** - * Attempts to resolve a child session id for a pending task tool by matching - * against sessions in the directory store. - * - * Returns `undefined` when: - * - Not a task tool - * - Task is finalized - * - Parent session is unknown - * - No unambiguous match found - */ -export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined { - const { - isTaskTool, - parentSessionId, - taskStartTime, - isTaskFinalized = false, - sessions, - sessionStatusMap, - hasRetried = false, - } = params; - - if (!isTaskTool || isTaskFinalized || !parentSessionId || typeof taskStartTime !== 'number') { - return undefined; - } - - const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS; - const latestAllowed = taskStartTime + windowMs; - - // Filter candidate sessions: parentID matches and created shortly after task start. - const candidates = sessions.filter((session) => { - if (!session?.id || session.parentID !== parentSessionId) { - return false; - } - const created = session.time?.created; - if (typeof created !== 'number') { - return false; - } - return created >= taskStartTime && created <= latestAllowed; - }); - - if (candidates.length === 0) { - return undefined; - } - - // If exactly one candidate, return it regardless of status - if (candidates.length === 1) { - return candidates[0].id; - } - - // Multiple candidates: try to disambiguate by finding exactly one live (busy/retry) - const liveCandidates = candidates.filter((session) => { - const status = sessionStatusMap?.[session.id]; - return status != null && LIVE_STATUSES.has(status.type); - }); - - if (liveCandidates.length === 1) { - return liveCandidates[0].id; - } - - // Ambiguous — do not guess - return undefined; -} +/** + * resolveFallbackTaskSessionId — pure helper that resolves a pending task tool + * to a child session from the directory session store when explicit taskSessionId + * metadata is delayed. + * + * Conservative: only returns a session id when the match is unambiguous. + */ + +import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client'; + +/** + * Fallback is intentionally narrow: only sessions created shortly after the + * task started are eligible. This avoids binding to earlier or later sibling + * subagent sessions when explicit task metadata is delayed. + */ +/** + * Narrow initial window avoids binding to wrong sessions on first attempt. + * Wide window on retry handles late-appearing child sessions under load. + */ +const TASK_SESSION_MATCH_WINDOW_MS = 3000; +const TASK_SESSION_MATCH_WINDOW_WIDE_MS = 8000; + +const LIVE_STATUSES = new Set(['busy', 'retry']); + +export interface ResolveFallbackParams { + /** True when this tool is a task tool */ + isTaskTool: boolean; + /** The parent session id (current session) */ + parentSessionId: string | undefined; + /** When the task tool started (ms timestamp) */ + taskStartTime: number | undefined; + /** True when the task tool is finalized (completed/error/etc.) */ + isTaskFinalized?: boolean; + /** Sessions from the directory store */ + sessions: Session[]; + /** Session status map from the sync store */ + sessionStatusMap?: Record; + /** True when a previous resolution attempt has already failed (enables wider window) */ + hasRetried?: boolean; +} + +/** + * Attempts to resolve a child session id for a pending task tool by matching + * against sessions in the directory store. + * + * Returns `undefined` when: + * - Not a task tool + * - Task is finalized + * - Parent session is unknown + * - No unambiguous match found + */ +export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined { + const { + isTaskTool, + parentSessionId, + taskStartTime, + isTaskFinalized = false, + sessions, + sessionStatusMap, + hasRetried = false, + } = params; + + if (!isTaskTool || !parentSessionId) { + return undefined; + } + + // Filter candidate sessions: parentID matches the current session. + let candidates = sessions.filter((session) => { + if (!session?.id || session.parentID !== parentSessionId) { + return false; + } + return true; + }); + + // When the task is still running, apply no time window — late-appearing + // child sessions should still match. Once finalized, restrict to sessions + // created within a generous window around the task start to avoid binding + // to stale siblings. If taskStartTime is unavailable (cross-OpenCode + // sessions), skip the time filter entirely. + if (typeof taskStartTime === 'number' && isTaskFinalized) { + const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS; + const latestAllowed = taskStartTime + windowMs; + candidates = candidates.filter((session) => { + const created = session.time?.created; + return typeof created === 'number' && created >= taskStartTime - 2_000 && created <= latestAllowed; + }); + } + + if (candidates.length === 0) { + return undefined; + } + + // If exactly one candidate, return it regardless of status + if (candidates.length === 1) { + return candidates[0].id; + } + + // Multiple candidates: try to disambiguate by finding exactly one live (busy/retry) + const liveCandidates = candidates.filter((session) => { + const status = sessionStatusMap?.[session.id]; + return status != null && LIVE_STATUSES.has(status.type); + }); + + if (liveCandidates.length === 1) { + return liveCandidates[0].id; + } + + // All idle: pick the most recently created child session. + // This handles the common case where a delegation completed and the + // user is viewing the task tool result inline. + if (liveCandidates.length === 0 && candidates.length > 1) { + const sorted = [...candidates].sort((a, b) => { + const aCreated = typeof a.time?.created === 'number' ? a.time.created : 0; + const bCreated = typeof b.time?.created === 'number' ? b.time.created : 0; + return bCreated - aCreated; + }); + return sorted[0].id; + } + + // Ambiguous — do not guess + return undefined; +} diff --git a/packages/ui/src/components/chat/message/parts/taskSessionIdParser.ts b/packages/ui/src/components/chat/message/parts/taskSessionIdParser.ts new file mode 100644 index 00000000..65da370d --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/taskSessionIdParser.ts @@ -0,0 +1,4 @@ +export const readTaskTagSessionIdFromOutput = (output: string): string | undefined => { + const taskTagMatch = output.match(/]*)?>/i); + return taskTagMatch?.[1]; +}; diff --git a/packages/ui/src/components/diagram/DiagramEditor.tsx b/packages/ui/src/components/diagram/DiagramEditor.tsx new file mode 100644 index 00000000..088cb3e7 --- /dev/null +++ b/packages/ui/src/components/diagram/DiagramEditor.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { DrawIoEmbed } from 'react-drawio'; +import { cn } from '@/lib/utils'; + +export interface DiagramEditorHandle { + getXml: () => string; +} + +export interface DiagramEditorProps { + xml: string; + readOnly?: boolean; + className?: string; + onChange?: (xml: string) => void; +} + +const BLANK_XML = ''; + +function detectDark(): boolean { + if (typeof document === 'undefined') return false; + const theme = document.documentElement.getAttribute('data-theme'); + return theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches); +} + +export const DiagramEditor = React.forwardRef( + function DiagramEditor({ xml, readOnly, className, onChange }, ref) { + const latestXmlRef = React.useRef(xml); + const drawioRef = React.useRef>(null); + const hasShownTemplate = React.useRef(false); + const [isDark, setIsDark] = React.useState(detectDark); + const stableXmlRef = React.useRef(xml); + + // When the parent switches files (xml prop changes), reset the stable + // reference so the new content renders instead of the first file's content. + const prevXmlRef = React.useRef(xml); + if (prevXmlRef.current !== xml) { + prevXmlRef.current = xml; + stableXmlRef.current = xml; + latestXmlRef.current = xml; + } + + const prevIsDark = React.useRef(isDark); + if (prevIsDark.current !== isDark) { + prevIsDark.current = isDark; + stableXmlRef.current = latestXmlRef.current; + } + + const containerRef = React.useRef(null); + + React.useEffect(() => { + const check = () => setIsDark(detectDark()); + check(); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + return () => observer.disconnect(); + }, []); + + // Focus the iframe once on mount so keyboard shortcuts work without + // clicking the canvas. Intentionally not keyed on isDark — re-focusing + // on every theme toggle would steal keyboard focus from the user. + React.useEffect(() => { + const id = setTimeout(() => { + containerRef.current?.querySelector('.diagrams-iframe')?.focus(); + }, 600); + return () => clearTimeout(id); + }, []); + + React.useImperativeHandle(ref, () => ({ + getXml: () => latestXmlRef.current, + })); + + const handleLoad = React.useCallback(() => { + if (!xml && !hasShownTemplate.current) { + hasShownTemplate.current = true; + setTimeout(() => { + drawioRef.current?.template({}); + }, 500); + } + }, [xml]); + + const handleAutoSave = React.useCallback((data: { xml: string }) => { + latestXmlRef.current = data.xml; + onChange?.(data.xml); + }, [onChange]); + + return ( +
+ +
+ ); + }, +); diff --git a/packages/ui/src/components/diagram/index.ts b/packages/ui/src/components/diagram/index.ts new file mode 100644 index 00000000..9dcc2d6c --- /dev/null +++ b/packages/ui/src/components/diagram/index.ts @@ -0,0 +1 @@ +export { DiagramEditor, type DiagramEditorProps, type DiagramEditorHandle } from './DiagramEditor'; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 8b896873..4712c112 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1725,6 +1725,7 @@ export const Header: React.FC = ({ { id: 'files', label: t('layout.mainTab.files'), icon: "folder-6" }, { id: 'terminal', label: t('layout.mainTab.terminal'), icon: "terminal-box" }, { id: 'context', label: t('layout.mainTab.context'), icon: "file-list-2" }, + { id: 'diagram', label: t('layout.mainTab.diagram'), icon: 'file' }, ); return base; diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index cf042962..2f84da12 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -31,6 +31,7 @@ import { PlanView } from '@/components/views/PlanView'; // Heavy views loaded on-demand to reduce initial bundle parse time. const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView }))); +const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView }))); const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow }))); @@ -408,6 +409,8 @@ export const MainLayout: React.FC = () => { return ; case 'context': return ; + case 'diagram': + return ; default: return null; } diff --git a/packages/ui/src/components/views/DiagramView.tsx b/packages/ui/src/components/views/DiagramView.tsx new file mode 100644 index 00000000..e2fdd5ad --- /dev/null +++ b/packages/ui/src/components/views/DiagramView.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { useUIStore } from '@/stores/useUIStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useI18n } from '@/lib/i18n'; +import { DiagramEditor, type DiagramEditorHandle } from '@/components/diagram'; +import { Icon } from '@/components/icon/Icon'; + +export function DiagramView() { + const { t } = useI18n(); + const { files } = useRuntimeAPIs(); + + const [filePath, setFilePath] = React.useState(null); + const [xml, setXml] = React.useState(''); + const [loading, setLoading] = React.useState(true); + const editorRef = React.useRef(null); + const pendingDiagramFile = useUIStore((state) => state.pendingDiagramFile); + + const loadFile = React.useCallback(async (path: string) => { + setLoading(true); + setFilePath(path); + try { + const result = await files?.readFile?.(path); + if (result) { + setXml(result.content); + } + } catch { + setXml(''); + } finally { + setLoading(false); + } + }, [files]); + + React.useEffect(() => { + if (!pendingDiagramFile) { + return; + } + const pending = useUIStore.getState().consumePendingDiagramFile(); + if (pending) { + void loadFile(pending); + } + }, [loadFile, pendingDiagramFile]); + + const saveDiagram = React.useCallback(async () => { + const newXml = editorRef.current?.getXml(); + if (filePath && files?.writeFile && newXml && newXml !== xml) { + await files.writeFile(filePath, newXml); + setXml(newXml); + } + }, [filePath, files, xml]); + + const fileName = filePath ? filePath.split('/').pop() || filePath : ''; + + if (!filePath) { + return ( +
+
+ {t('filesView.editor.pickFileFromTree')} +
+
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + {fileName} + + +
+
+ +
+
+ ); +} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 3159f23a..d65b1321 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -1,3756 +1,3927 @@ -import React from 'react'; -import { runtimeFetch } from '@/lib/runtime-fetch'; - -import { toast } from '@/components/ui'; -import { copyTextToClipboard } from '@/lib/clipboard'; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; -import { GoToLineDialog } from './GoToLineDialog'; -import { PreviewToggleButton } from './PreviewToggleButton'; -import { JsonTreeView } from '@/components/ui/JsonTreeView'; -import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; -import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension'; -import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; -import { File as PierreFile } from '@pierre/diffs/react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { useDebouncedValue } from '@/hooks/useDebouncedValue'; -import { useFileSearchStore } from '@/stores/useFileSearchStore'; -import { useDeviceInfo } from '@/lib/device'; -import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; -import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers'; -import { getRuntimeUrlResolver } from '@/lib/runtime-url'; -import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; -import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; -import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { EditorView } from '@codemirror/view'; -import type { Extension } from '@codemirror/state'; -import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { useUIStore } from '@/stores/useUIStore'; -import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; -import { useGitStatus } from '@/stores/useGitStore'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments'; -import { opencodeClient } from '@/lib/opencode/client'; -import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; -import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; -import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; -import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; -import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; -import { Icon } from "@/components/icon/Icon"; -import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; -import { getDefaultTheme } from '@/lib/theme/themes'; -import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; -import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; -import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; -import { useI18n } from '@/lib/i18n'; - -type FileNode = { - name: string; - path: string; - type: 'file' | 'directory'; - extension?: string; - relativePath?: string; -}; - -type FileStatSnapshot = { - path: string; - size: number; - mtimeMs?: number; -}; - -type SelectedLineRange = { - start: number; - end: number; -}; - -const getParentDirectoryPath = (path: string): string => { - const normalized = normalizePath(path); - if (!normalized) return ''; - if (normalized === '/' || /^[A-Za-z]:\/$/.test(normalized)) { - return normalized; - } - - const lastSlash = normalized.lastIndexOf('/'); - if (lastSlash < 0) { - return normalized; - } - if (lastSlash === 0) { - return '/'; - } - - const parent = normalized.slice(0, lastSlash); - if (/^[A-Za-z]:$/.test(parent)) { - return `${parent}/`; - } - return parent; -}; - -const OpenInAppListIcon = ({ label, iconDataUrl }: { label: string; iconDataUrl?: string }) => { - const [failed, setFailed] = React.useState(false); - const initial = label.trim().slice(0, 1).toUpperCase() || '?'; - - if (iconDataUrl && !failed) { - return ( - setFailed(true)} - /> - ); - } - - return ( - - {initial} - - ); -}; - -const sortNodes = (items: FileNode[]) => - items.slice().sort((a, b) => { - if (a.type !== b.type) { - return a.type === 'directory' ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - -const normalizePath = (value: string): string => { - if (!value) return ''; - - const raw = value.replace(/\\/g, '/'); - const hadUncPrefix = raw.startsWith('//'); - - let normalized = raw.replace(/\/+/g, '/'); - if (hadUncPrefix && !normalized.startsWith('//')) { - normalized = `/${normalized}`; - } - - const isUnixRoot = normalized === '/'; - const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized); - if (!isUnixRoot && !isWindowsDriveRoot) { - normalized = normalized.replace(/\/+$/, ''); - } - - return normalized; -}; - -const isAbsolutePath = (value: string): boolean => { - return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); -}; - -const toComparablePath = (value: string): string => { - if (/^[A-Za-z]:\//.test(value)) { - return value.toLowerCase(); - } - return value; -}; - -const isPathWithinRoot = (path: string, root: string): boolean => { - const normalizedRoot = normalizePath(root); - const normalizedPath = normalizePath(path); - if (!normalizedRoot || !normalizedPath) return false; - - const comparableRoot = toComparablePath(normalizedRoot); - const comparablePath = toComparablePath(normalizedPath); - return comparablePath === comparableRoot || comparablePath.startsWith(`${comparableRoot}/`); -}; - -const getAncestorPaths = (filePath: string, root: string): string[] => { - const normalizedRoot = normalizePath(root); - const normalizedFile = normalizePath(filePath); - - // Ensure file is within root - if (!isPathWithinRoot(normalizedFile, normalizedRoot)) return []; - - const relative = normalizedFile.slice(normalizedRoot.length).replace(/^\//, ''); - const parts = relative.split('/'); - const ancestors: string[] = []; - let current = normalizedRoot; - - for (let i = 0; i < parts.length - 1; i++) { - current = current ? `${current}/${parts[i]}` : parts[i]; - ancestors.push(current); - } - return ancestors; -}; - -const getDisplayPath = (root: string | null, path: string): string => { - if (!path) { - return ''; - } - - const normalizedFilePath = normalizePath(path); - if (!root || !isPathWithinRoot(normalizedFilePath, root)) { - return normalizedFilePath; - } - - const relative = normalizedFilePath.slice(root.length); - return relative.startsWith('/') ? relative.slice(1) : relative; -}; - -const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']); - -type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted'; - -const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => { - const color = { - open: 'var(--status-info)', - modified: 'var(--status-warning)', - 'git-modified': 'var(--status-warning)', - 'git-added': 'var(--status-success)', - 'git-deleted': 'var(--status-error)', - }[status]; - - return ; -}; - -const ScrollingFileName: React.FC<{ name: string }> = ({ name }) => { - const containerRef = React.useRef(null); - const textRef = React.useRef(null); - const [overflowing, setOverflowing] = React.useState(false); - - React.useLayoutEffect(() => { - const container = containerRef.current; - const text = textRef.current; - if (!container || !text) { - return; - } - - const updateOverflow = () => { - setOverflowing(text.scrollWidth > container.clientWidth + 1); - }; - - updateOverflow(); - const resizeObserver = new ResizeObserver(updateOverflow); - resizeObserver.observe(container); - resizeObserver.observe(text); - - return () => { - resizeObserver.disconnect(); - }; - }, [name]); - - return ( - - - {overflowing ? ( - - {name} - - - ) : ( - {name} - )} - - ); -}; - -const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name); - -const shouldIgnorePath = (path: string): boolean => { - const normalized = normalizePath(path); - return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/'); -}; - -const isDirectoryReadError = (error: unknown): boolean => { - const message = error instanceof Error ? error.message : String(error ?? ''); - const normalized = message.toLowerCase(); - return normalized.includes('is a directory') || normalized.includes('eisdir'); -}; - -const isFileMissingError = (error: unknown): boolean => { - const message = error instanceof Error ? error.message : String(error ?? ''); - const normalized = message.toLowerCase(); - return normalized.includes('file not found') - || normalized.includes('enoent') - || normalized.includes('no such file') - || normalized.includes('does not exist'); -}; - -const MAX_VIEW_CHARS = 200_000; -const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled'; -type FileLineEnding = '\n' | '\r\n'; - -const detectFileLineEnding = (content: string): FileLineEnding => { - let crlf = 0; - let lf = 0; - - for (let index = 0; index < content.length; index += 1) { - if (content.charCodeAt(index) !== 10) { - continue; - } - if (index > 0 && content.charCodeAt(index - 1) === 13) { - crlf += 1; - } else { - lf += 1; - } - } - - return crlf > lf ? '\r\n' : '\n'; -}; - -const normalizeEditorLineEndings = (content: string): string => content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - -const serializeEditorContent = (content: string, lineEnding: FileLineEnding): string => { - const normalized = normalizeEditorLineEndings(content); - return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized; -}; - -const getInitialAutoSaveEnabled = (): boolean => { - if (typeof window === 'undefined') { - return true; - } - - try { - return window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_KEY) !== 'false'; - } catch { - return true; - } -}; - -const getFileIcon = (filePath: string, extension?: string): React.ReactNode => { - return ; -}; - -const isMarkdownFile = (path: string): boolean => { - if (!path) return false; - const ext = path.toLowerCase().split('.').pop(); - return ext === 'md' || ext === 'markdown'; -}; - -const isJsonFile = (path: string): boolean => { - if (!path) return false; - const ext = path.toLowerCase().split('.').pop(); - return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson'; -}; - -const isHtmlFile = (path: string): boolean => { - if (!path) return false; - const ext = path.toLowerCase().split('.').pop(); - return ext === 'html' || ext === 'htm'; -}; - -interface FileRowProps { - node: FileNode; - root: string; - isExpanded: boolean; - isActive: boolean; - isMobile: boolean; - alwaysShowActions: boolean; - status?: FileStatus | null; - badge?: { modified: number; added: number } | null; - permissions: { - canRename: boolean; - canCreateFile: boolean; - canCreateFolder: boolean; - canDelete: boolean; - canReveal: boolean; - }; - downloadFile?: (path: string) => Promise; - contextMenuPath: string | null; - setContextMenuPath: (path: string | null) => void; - onSelect: (node: FileNode) => void; - onToggle: (path: string) => void; - onRevealPath: (path: string) => void; - onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; -} - -const FileRow: React.FC = ({ - node, - root, - isExpanded, - isActive, - isMobile, - alwaysShowActions, - status, - badge, - permissions, - downloadFile, - contextMenuPath, - setContextMenuPath, - onSelect, - onToggle, - onRevealPath, - onOpenDialog, -}) => { - const { t } = useI18n(); - const isDir = node.type === 'directory'; - const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; - - const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { - if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) { - return; - } - event?.preventDefault(); - setContextMenuPath(node.path); - }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setContextMenuPath]); - - const handleInteraction = React.useCallback(() => { - if (isDir) { - onToggle(node.path); - } else { - onSelect(node); - } - }, [isDir, node, onSelect, onToggle]); - - const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - setContextMenuPath(node.path); - }, [node.path, setContextMenuPath]); - - return ( -
- - {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && ( -
- setContextMenuPath(open ? node.path : null)} - > - - - - setContextMenuPath(null)}> - {canRename && ( - { e.stopPropagation(); onOpenDialog('rename', node); }}> - {t('sidebarFilesTree.menu.rename')} - - )} - { - e.stopPropagation(); - void copyTextToClipboard(node.path).then((result) => { - if (result.ok) { - toast.success(t('sidebarFilesTree.toast.pathCopied')); - return; - } - toast.error(t('sidebarFilesTree.toast.copyFailed')); - }); - }}> - {t('sidebarFilesTree.menu.copyPath')} - - { - e.stopPropagation(); - const relativePath = getDisplayPath(root, node.path) || node.path; - void copyTextToClipboard(relativePath).then((result) => { - if (result.ok) { - toast.success(t('filesView.toast.relativePathCopied')); - return; - } - toast.error(t('sidebarFilesTree.toast.copyFailed')); - }); - }}> - {t('filesView.tree.menu.copyRelativePath')} - - {!isDir && downloadFile && ( - { - e.stopPropagation(); - void downloadFile(node.path); - }}> - {t('sidebarFilesTree.menu.save')} - - )} - {canReveal && ( - { e.stopPropagation(); onRevealPath(node.path); }}> - {t(getRevealLabelKey())} - - )} - {isDir && (canCreateFile || canCreateFolder) && ( - <> - - {canCreateFile && ( - { e.stopPropagation(); onOpenDialog('createFile', node); }}> - {t('sidebarFilesTree.menu.newFile')} - - )} - {canCreateFolder && ( - { e.stopPropagation(); onOpenDialog('createFolder', node); }}> - {t('sidebarFilesTree.menu.newFolder')} - - )} - - )} - {canDelete && ( - <> - - { e.stopPropagation(); onOpenDialog('delete', node); }} - className="text-destructive focus:text-destructive" - > - {t('sidebarFilesTree.menu.delete')} - - - )} - - -
- )} -
- ); -}; - -interface DialogsProps { - activeDialog: 'createFile' | 'createFolder' | 'rename' | 'delete' | null; - dialogData: { path: string; name?: string; type?: 'file' | 'directory' } | null; - dialogInputValue: string; - onDialogInputChange: (value: string) => void; - isDialogSubmitting: boolean; - onDialogSubmit: (e?: React.FormEvent) => Promise; - onClose: () => void; - inputRef: React.RefObject; -} - -const Dialogs: React.FC = ({ - activeDialog, - dialogData, - dialogInputValue, - onDialogInputChange, - isDialogSubmitting, - onDialogSubmit, - onClose, - inputRef, -}) => { - const { t } = useI18n(); - - return ( - !open && onClose()}> - - - - {activeDialog === 'createFile' && t('filesView.dialog.createFile.title')} - {activeDialog === 'createFolder' && t('filesView.dialog.createFolder.title')} - {activeDialog === 'rename' && t('filesView.dialog.rename.title')} - {activeDialog === 'delete' && t('filesView.dialog.delete.title')} - - - {activeDialog === 'createFile' && t('filesView.dialog.createFile.description', { path: dialogData?.path ?? t('filesView.dialog.rootFallback') })} - {activeDialog === 'createFolder' && t('filesView.dialog.createFolder.description', { path: dialogData?.path ?? t('filesView.dialog.rootFallback') })} - {activeDialog === 'rename' && t('filesView.dialog.rename.description', { name: dialogData?.name ?? '' })} - {activeDialog === 'delete' && t('filesView.dialog.delete.description', { name: dialogData?.name ?? '' })} - - - - {activeDialog !== 'delete' && ( -
- onDialogInputChange(e.target.value)} - placeholder={activeDialog === 'rename' ? t('filesView.dialog.rename.placeholder') : t('filesView.dialog.namePlaceholder')} - onKeyDown={(e) => { - if (e.key === 'Enter') { - void onDialogSubmit(); - } - }} - ref={inputRef} - /> -
- )} - - - - - -
-
- ); -}; - -interface FilesViewProps { - mode?: 'full' | 'editor-only'; -} - -export const FilesView: React.FC = ({ mode = 'full' }) => { - const { t } = useI18n(); - const { files, runtime } = useRuntimeAPIs(); - const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem(); - const { isMobile, isTablet, screenWidth } = useDeviceInfo(); - const alwaysShowActions = isMobile || isTablet; - const showHidden = useDirectoryShowHidden(); - const showGitignored = useFilesViewShowGitignored(); - - const currentDirectory = useEffectiveDirectory() ?? ''; - const root = normalizePath(currentDirectory.trim()); - const showEditorTabsRow = isMobile || mode !== 'editor-only'; - const suppressFileLoadingIndicator = mode === 'editor-only' && !isMobile; - const searchFiles = useFileSearchStore((state) => state.searchFiles); - const gitStatus = useGitStatus(currentDirectory); - - const [searchQuery, setSearchQuery] = React.useState(''); - const debouncedSearchQuery = useDebouncedValue(searchQuery, 200); - const searchInputRef = React.useRef(null); - - const [showMobilePageContent, setShowMobilePageContent] = React.useState(false); - const [wrapLines, setWrapLines] = React.useState(true); - const [isFullscreen, setIsFullscreen] = React.useState(false); - const [isSearchOpen, setIsSearchOpen] = React.useState(false); - const [isFloatingToolbarOpen, setIsFloatingToolbarOpen] = React.useState(false); - const floatingToolbarRef = React.useRef(null); - const toolbarDropdownOpenCountRef = React.useRef(0); - - const handleToolbarDropdownOpenChange = React.useCallback((open: boolean) => { - toolbarDropdownOpenCountRef.current = Math.max( - 0, - toolbarDropdownOpenCountRef.current + (open ? 1 : -1), - ); - }, []); - - const isClickInsidePortalledMenu = React.useCallback((target: EventTarget | null) => { - if (!(target instanceof Element)) return false; - return target.closest('[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-item"]') !== null; - }, []); - - React.useEffect(() => { - if (!isFloatingToolbarOpen) return; - const handler = (event: MouseEvent) => { - if (toolbarDropdownOpenCountRef.current > 0) return; - if (isClickInsidePortalledMenu(event.target)) return; - if (floatingToolbarRef.current && !floatingToolbarRef.current.contains(event.target as Node)) { - setIsFloatingToolbarOpen(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [isClickInsidePortalledMenu, isFloatingToolbarOpen]); - type TextViewMode = 'view' | 'edit'; - type PreviewViewMode = 'preview' | 'edit'; - - const [textViewMode, setTextViewMode] = React.useState('edit'); - const [mdViewMode, setMdViewMode] = React.useState('edit'); +import React from 'react'; +import { runtimeFetch } from '@/lib/runtime-fetch'; + +import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; +import { GoToLineDialog } from './GoToLineDialog'; +import { PreviewToggleButton } from './PreviewToggleButton'; +import { JsonTreeView } from '@/components/ui/JsonTreeView'; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension'; +import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; +import { File as PierreFile } from '@pierre/diffs/react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useFileSearchStore } from '@/stores/useFileSearchStore'; +import { useDeviceInfo } from '@/lib/device'; +import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; +import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile } from '@/lib/toolHelpers'; +import { getRuntimeUrlResolver } from '@/lib/runtime-url'; +import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; +import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; +import { DiagramEditor } from '@/components/diagram'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { EditorView } from '@codemirror/view'; +import type { Extension } from '@codemirror/state'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useUIStore } from '@/stores/useUIStore'; +import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; +import { useGitStatus } from '@/stores/useGitStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments'; +import { opencodeClient } from '@/lib/opencode/client'; +import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; +import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; +import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { Icon } from "@/components/icon/Icon"; +import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; +import { getDefaultTheme } from '@/lib/theme/themes'; +import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; +import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; +import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { useI18n } from '@/lib/i18n'; + +type FileNode = { + name: string; + path: string; + type: 'file' | 'directory'; + extension?: string; + relativePath?: string; +}; + +type FileStatSnapshot = { + path: string; + size: number; + mtimeMs?: number; +}; + +type SelectedLineRange = { + start: number; + end: number; +}; + +const getParentDirectoryPath = (path: string): string => { + const normalized = normalizePath(path); + if (!normalized) return ''; + if (normalized === '/' || /^[A-Za-z]:\/$/.test(normalized)) { + return normalized; + } + + const lastSlash = normalized.lastIndexOf('/'); + if (lastSlash < 0) { + return normalized; + } + if (lastSlash === 0) { + return '/'; + } + + const parent = normalized.slice(0, lastSlash); + if (/^[A-Za-z]:$/.test(parent)) { + return `${parent}/`; + } + return parent; +}; + +const OpenInAppListIcon = ({ label, iconDataUrl }: { label: string; iconDataUrl?: string }) => { + const [failed, setFailed] = React.useState(false); + const initial = label.trim().slice(0, 1).toUpperCase() || '?'; + + if (iconDataUrl && !failed) { + return ( + setFailed(true)} + /> + ); + } + + return ( + + {initial} + + ); +}; + +const sortNodes = (items: FileNode[]) => + items.slice().sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + +const normalizePath = (value: string): string => { + if (!value) return ''; + + const raw = value.replace(/\\/g, '/'); + const hadUncPrefix = raw.startsWith('//'); + + let normalized = raw.replace(/\/+/g, '/'); + if (hadUncPrefix && !normalized.startsWith('//')) { + normalized = `/${normalized}`; + } + + const isUnixRoot = normalized === '/'; + const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized); + if (!isUnixRoot && !isWindowsDriveRoot) { + normalized = normalized.replace(/\/+$/, ''); + } + + return normalized; +}; + +const isAbsolutePath = (value: string): boolean => { + return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); +}; + +const toComparablePath = (value: string): string => { + if (/^[A-Za-z]:\//.test(value)) { + return value.toLowerCase(); + } + return value; +}; + +const isPathWithinRoot = (path: string, root: string): boolean => { + const normalizedRoot = normalizePath(root); + const normalizedPath = normalizePath(path); + if (!normalizedRoot || !normalizedPath) return false; + + const comparableRoot = toComparablePath(normalizedRoot); + const comparablePath = toComparablePath(normalizedPath); + return comparablePath === comparableRoot || comparablePath.startsWith(`${comparableRoot}/`); +}; + +const getAncestorPaths = (filePath: string, root: string): string[] => { + const normalizedRoot = normalizePath(root); + const normalizedFile = normalizePath(filePath); + + // Ensure file is within root + if (!isPathWithinRoot(normalizedFile, normalizedRoot)) return []; + + const relative = normalizedFile.slice(normalizedRoot.length).replace(/^\//, ''); + const parts = relative.split('/'); + const ancestors: string[] = []; + let current = normalizedRoot; + + for (let i = 0; i < parts.length - 1; i++) { + current = current ? `${current}/${parts[i]}` : parts[i]; + ancestors.push(current); + } + return ancestors; +}; + +const getDisplayPath = (root: string | null, path: string): string => { + if (!path) { + return ''; + } + + const normalizedFilePath = normalizePath(path); + if (!root || !isPathWithinRoot(normalizedFilePath, root)) { + return normalizedFilePath; + } + + const relative = normalizedFilePath.slice(root.length); + return relative.startsWith('/') ? relative.slice(1) : relative; +}; + +const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']); + +type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted'; + +const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => { + const color = { + open: 'var(--status-info)', + modified: 'var(--status-warning)', + 'git-modified': 'var(--status-warning)', + 'git-added': 'var(--status-success)', + 'git-deleted': 'var(--status-error)', + }[status]; + + return ; +}; + +const ScrollingFileName: React.FC<{ name: string }> = ({ name }) => { + const containerRef = React.useRef(null); + const textRef = React.useRef(null); + const [overflowing, setOverflowing] = React.useState(false); + + React.useLayoutEffect(() => { + const container = containerRef.current; + const text = textRef.current; + if (!container || !text) { + return; + } + + const updateOverflow = () => { + setOverflowing(text.scrollWidth > container.clientWidth + 1); + }; + + updateOverflow(); + const resizeObserver = new ResizeObserver(updateOverflow); + resizeObserver.observe(container); + resizeObserver.observe(text); + + return () => { + resizeObserver.disconnect(); + }; + }, [name]); + + return ( + + + {overflowing ? ( + + {name} + + + ) : ( + {name} + )} + + ); +}; + +const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name); + +const shouldIgnorePath = (path: string): boolean => { + const normalized = normalizePath(path); + return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/'); +}; + +const isDirectoryReadError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error ?? ''); + const normalized = message.toLowerCase(); + return normalized.includes('is a directory') || normalized.includes('eisdir'); +}; + +const isFileMissingError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error ?? ''); + const normalized = message.toLowerCase(); + return normalized.includes('file not found') + || normalized.includes('enoent') + || normalized.includes('no such file') + || normalized.includes('does not exist'); +}; + +const MAX_VIEW_CHARS = 200_000; +const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled'; +type FileLineEnding = '\n' | '\r\n'; + +const detectFileLineEnding = (content: string): FileLineEnding => { + let crlf = 0; + let lf = 0; + + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) !== 10) { + continue; + } + if (index > 0 && content.charCodeAt(index - 1) === 13) { + crlf += 1; + } else { + lf += 1; + } + } + + return crlf > lf ? '\r\n' : '\n'; +}; + +const normalizeEditorLineEndings = (content: string): string => content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + +const serializeEditorContent = (content: string, lineEnding: FileLineEnding): string => { + const normalized = normalizeEditorLineEndings(content); + return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized; +}; + +const getInitialAutoSaveEnabled = (): boolean => { + if (typeof window === 'undefined') { + return true; + } + + try { + return window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_KEY) !== 'false'; + } catch { + return true; + } +}; + +const getFileIcon = (filePath: string, extension?: string): React.ReactNode => { + return ; +}; + +const isMarkdownFile = (path: string): boolean => { + if (!path) return false; + const ext = path.toLowerCase().split('.').pop(); + return ext === 'md' || ext === 'markdown'; +}; + +const isJsonFile = (path: string): boolean => { + if (!path) return false; + const ext = path.toLowerCase().split('.').pop(); + return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson'; +}; + +const isHtmlFile = (path: string): boolean => { + if (!path) return false; + const ext = path.toLowerCase().split('.').pop(); + return ext === 'html' || ext === 'htm'; +}; + +interface FileRowProps { + node: FileNode; + root: string; + isExpanded: boolean; + isActive: boolean; + isMobile: boolean; + alwaysShowActions: boolean; + status?: FileStatus | null; + badge?: { modified: number; added: number } | null; + permissions: { + canRename: boolean; + canCreateFile: boolean; + canCreateFolder: boolean; + canDelete: boolean; + canReveal: boolean; + }; + downloadFile?: (path: string) => Promise; + contextMenuPath: string | null; + setContextMenuPath: (path: string | null) => void; + onSelect: (node: FileNode) => void; + onToggle: (path: string) => void; + onRevealPath: (path: string) => void; + onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; +} + +const FileRow: React.FC = ({ + node, + root, + isExpanded, + isActive, + isMobile, + alwaysShowActions, + status, + badge, + permissions, + downloadFile, + contextMenuPath, + setContextMenuPath, + onSelect, + onToggle, + onRevealPath, + onOpenDialog, +}) => { + const { t } = useI18n(); + const isDir = node.type === 'directory'; + const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; + + const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { + if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) { + return; + } + event?.preventDefault(); + setContextMenuPath(node.path); + }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setContextMenuPath]); + + const handleInteraction = React.useCallback(() => { + if (isDir) { + onToggle(node.path); + } else { + onSelect(node); + } + }, [isDir, node, onSelect, onToggle]); + + const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setContextMenuPath(node.path); + }, [node.path, setContextMenuPath]); + + return ( +
+ + {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && ( +
+ setContextMenuPath(open ? node.path : null)} + > + + + + setContextMenuPath(null)}> + {canRename && ( + { e.stopPropagation(); onOpenDialog('rename', node); }}> + {t('sidebarFilesTree.menu.rename')} + + )} + { + e.stopPropagation(); + void copyTextToClipboard(node.path).then((result) => { + if (result.ok) { + toast.success(t('sidebarFilesTree.toast.pathCopied')); + return; + } + toast.error(t('sidebarFilesTree.toast.copyFailed')); + }); + }}> + {t('sidebarFilesTree.menu.copyPath')} + + { + e.stopPropagation(); + const relativePath = getDisplayPath(root, node.path) || node.path; + void copyTextToClipboard(relativePath).then((result) => { + if (result.ok) { + toast.success(t('filesView.toast.relativePathCopied')); + return; + } + toast.error(t('sidebarFilesTree.toast.copyFailed')); + }); + }}> + {t('filesView.tree.menu.copyRelativePath')} + + {!isDir && downloadFile && ( + { + e.stopPropagation(); + void downloadFile(node.path); + }}> + {t('sidebarFilesTree.menu.save')} + + )} + {canReveal && ( + { e.stopPropagation(); onRevealPath(node.path); }}> + {t(getRevealLabelKey())} + + )} + {isDir && (canCreateFile || canCreateFolder) && ( + <> + + {canCreateFile && ( + { e.stopPropagation(); onOpenDialog('createFile', node); }}> + {t('sidebarFilesTree.menu.newFile')} + + )} + {canCreateFolder && ( + { e.stopPropagation(); onOpenDialog('createFolder', node); }}> + {t('sidebarFilesTree.menu.newFolder')} + + )} + + )} + {canDelete && ( + <> + + { e.stopPropagation(); onOpenDialog('delete', node); }} + className="text-destructive focus:text-destructive" + > + {t('sidebarFilesTree.menu.delete')} + + + )} + + +
+ )} +
+ ); +}; + +interface DialogsProps { + activeDialog: 'createFile' | 'createFolder' | 'rename' | 'delete' | null; + dialogData: { path: string; name?: string; type?: 'file' | 'directory' } | null; + dialogInputValue: string; + onDialogInputChange: (value: string) => void; + isDialogSubmitting: boolean; + onDialogSubmit: (e?: React.FormEvent) => Promise; + onClose: () => void; + inputRef: React.RefObject; +} + +const Dialogs: React.FC = ({ + activeDialog, + dialogData, + dialogInputValue, + onDialogInputChange, + isDialogSubmitting, + onDialogSubmit, + onClose, + inputRef, +}) => { + const { t } = useI18n(); + + return ( + !open && onClose()}> + + + + {activeDialog === 'createFile' && t('filesView.dialog.createFile.title')} + {activeDialog === 'createFolder' && t('filesView.dialog.createFolder.title')} + {activeDialog === 'rename' && t('filesView.dialog.rename.title')} + {activeDialog === 'delete' && t('filesView.dialog.delete.title')} + + + {activeDialog === 'createFile' && t('filesView.dialog.createFile.description', { path: dialogData?.path ?? t('filesView.dialog.rootFallback') })} + {activeDialog === 'createFolder' && t('filesView.dialog.createFolder.description', { path: dialogData?.path ?? t('filesView.dialog.rootFallback') })} + {activeDialog === 'rename' && t('filesView.dialog.rename.description', { name: dialogData?.name ?? '' })} + {activeDialog === 'delete' && t('filesView.dialog.delete.description', { name: dialogData?.name ?? '' })} + + + + {activeDialog !== 'delete' && ( +
+ onDialogInputChange(e.target.value)} + placeholder={activeDialog === 'rename' ? t('filesView.dialog.rename.placeholder') : t('filesView.dialog.namePlaceholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') { + void onDialogSubmit(); + } + }} + ref={inputRef} + /> +
+ )} + + + + + +
+
+ ); +}; + +interface FilesViewProps { + mode?: 'full' | 'editor-only'; +} + +export const FilesView: React.FC = ({ mode = 'full' }) => { + const { t } = useI18n(); + const { files, runtime } = useRuntimeAPIs(); + const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem(); + const { isMobile, isTablet, screenWidth } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + const showHidden = useDirectoryShowHidden(); + const showGitignored = useFilesViewShowGitignored(); + + const currentDirectory = useEffectiveDirectory() ?? ''; + const root = normalizePath(currentDirectory.trim()); + const showEditorTabsRow = isMobile || mode !== 'editor-only'; + const suppressFileLoadingIndicator = mode === 'editor-only' && !isMobile; + const searchFiles = useFileSearchStore((state) => state.searchFiles); + const gitStatus = useGitStatus(currentDirectory); + + const [searchQuery, setSearchQuery] = React.useState(''); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 200); + const searchInputRef = React.useRef(null); + + const [showMobilePageContent, setShowMobilePageContent] = React.useState(false); + const [wrapLines, setWrapLines] = React.useState(true); + const [isFullscreen, setIsFullscreen] = React.useState(false); + const [isSearchOpen, setIsSearchOpen] = React.useState(false); + const [isFloatingToolbarOpen, setIsFloatingToolbarOpen] = React.useState(false); + const floatingToolbarRef = React.useRef(null); + const toolbarDropdownOpenCountRef = React.useRef(0); + + const handleToolbarDropdownOpenChange = React.useCallback((open: boolean) => { + toolbarDropdownOpenCountRef.current = Math.max( + 0, + toolbarDropdownOpenCountRef.current + (open ? 1 : -1), + ); + }, []); + + const isClickInsidePortalledMenu = React.useCallback((target: EventTarget | null) => { + if (!(target instanceof Element)) return false; + return target.closest('[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-item"]') !== null; + }, []); + + React.useEffect(() => { + if (!isFloatingToolbarOpen) return; + const handler = (event: MouseEvent) => { + if (toolbarDropdownOpenCountRef.current > 0) return; + if (isClickInsidePortalledMenu(event.target)) return; + if (floatingToolbarRef.current && !floatingToolbarRef.current.contains(event.target as Node)) { + setIsFloatingToolbarOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [isClickInsidePortalledMenu, isFloatingToolbarOpen]); + type TextViewMode = 'view' | 'edit'; + type PreviewViewMode = 'preview' | 'edit'; + + const [textViewMode, setTextViewMode] = React.useState('edit'); + const [mdViewMode, setMdViewMode] = React.useState('edit'); const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree'); const [htmlViewMode, setHtmlViewMode] = React.useState('edit'); + const [drawioViewMode, setDrawioViewMode] = React.useState('preview'); + const [drawioRemountNonce, setDrawioRemountNonce] = React.useState(0); const textViewModeByPathRef = React.useRef>({}); const mdViewModeByPathRef = React.useRef>({}); const htmlViewModeByPathRef = React.useRef>({}); - - const lightTheme = React.useMemo( - () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false), - [availableThemes, lightThemeId], - ); - const darkTheme = React.useMemo( - () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? getDefaultTheme(true), - [availableThemes, darkThemeId], - ); - - React.useEffect(() => { - ensurePierreThemeRegistered(lightTheme); - ensurePierreThemeRegistered(darkTheme); - }, [lightTheme, darkTheme]); - - const EMPTY_PATHS: string[] = React.useMemo(() => [], []); - const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); - const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); - const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); - const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath); - const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath); - const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); - const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); - const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath); - const expandPaths = useFilesViewTabsStore((state) => state.expandPaths); - - const toFileNode = React.useCallback((path: string): FileNode => { - const normalized = normalizePath(path); - const parts = normalized.split('/'); - const name = parts[parts.length - 1] || normalized; - const extension = name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; - return { - name, - path: normalized, - type: 'file', - extension, - }; - }, []); - - const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]); - const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]); - const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]); - const selectedFilePath = selectedFile?.path ?? ''; - const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root)); - const selectedFileReadOptions = React.useMemo( - () => ({ allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace }), - [mode, selectedFileIsOutsideWorkspace], - ); - - // Editor tabs horizontal scroll fades - const editorTabsScrollRef = React.useRef(null); - const [editorTabsOverflow, setEditorTabsOverflow] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false }); - const updateEditorTabsOverflow = React.useCallback(() => { - const el = editorTabsScrollRef.current; - if (!el) return; - setEditorTabsOverflow({ - left: el.scrollLeft > 2, - right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2, - }); - }, []); - const updateEditorTabsOverflowRef = React.useRef(updateEditorTabsOverflow); - updateEditorTabsOverflowRef.current = updateEditorTabsOverflow; - React.useEffect(() => { - const el = editorTabsScrollRef.current; - if (!el) return; - const handler = () => updateEditorTabsOverflowRef.current(); - handler(); - el.addEventListener('scroll', handler, { passive: true }); - const ro = new ResizeObserver(handler); - ro.observe(el); - return () => { - el.removeEventListener('scroll', handler); - ro.disconnect(); - }; - }, [openFiles.length]); - - const [childrenByDir, setChildrenByDir] = React.useState>({}); - const [loadErrorsByDir, setLoadErrorsByDir] = React.useState>({}); - const loadedDirsRef = React.useRef>(new Set()); - const inFlightDirsRef = React.useRef>(new Set()); - const activeDirectoryLoadIdsRef = React.useRef>(new Map()); - const nextDirectoryLoadIdRef = React.useRef(0); - - const [searchResults, setSearchResults] = React.useState([]); - const [searching, setSearching] = React.useState(false); - - const [fileContent, setFileContent] = React.useState(''); - const [fileLoading, setFileLoading] = React.useState(false); - const [fileError, setFileError] = React.useState(null); - const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); - const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState(''); - - const [loadedFilePath, setLoadedFilePath] = React.useState(null); - - const [draftContent, setDraftContent] = React.useState(''); - const [isSaving, setIsSaving] = React.useState(false); - const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState('\n'); + const drawioViewModeByPathRef = React.useRef>({}); + + const lightTheme = React.useMemo( + () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false), + [availableThemes, lightThemeId], + ); + const darkTheme = React.useMemo( + () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? getDefaultTheme(true), + [availableThemes, darkThemeId], + ); + + React.useEffect(() => { + ensurePierreThemeRegistered(lightTheme); + ensurePierreThemeRegistered(darkTheme); + }, [lightTheme, darkTheme]); + + const EMPTY_PATHS: string[] = React.useMemo(() => [], []); + const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); + const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); + const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); + const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath); + const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath); + const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); + const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); + const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath); + const expandPaths = useFilesViewTabsStore((state) => state.expandPaths); + + const toFileNode = React.useCallback((path: string): FileNode => { + const normalized = normalizePath(path); + const parts = normalized.split('/'); + const name = parts[parts.length - 1] || normalized; + const extension = name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; + return { + name, + path: normalized, + type: 'file', + extension, + }; + }, []); + + const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]); + const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]); + const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]); + const selectedFilePath = selectedFile?.path ?? ''; + const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root)); + const selectedFileReadOptions = React.useMemo( + () => ({ allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace }), + [mode, selectedFileIsOutsideWorkspace], + ); + + // Editor tabs horizontal scroll fades + const editorTabsScrollRef = React.useRef(null); + const [editorTabsOverflow, setEditorTabsOverflow] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false }); + const updateEditorTabsOverflow = React.useCallback(() => { + const el = editorTabsScrollRef.current; + if (!el) return; + setEditorTabsOverflow({ + left: el.scrollLeft > 2, + right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2, + }); + }, []); + const updateEditorTabsOverflowRef = React.useRef(updateEditorTabsOverflow); + updateEditorTabsOverflowRef.current = updateEditorTabsOverflow; + React.useEffect(() => { + const el = editorTabsScrollRef.current; + if (!el) return; + const handler = () => updateEditorTabsOverflowRef.current(); + handler(); + el.addEventListener('scroll', handler, { passive: true }); + const ro = new ResizeObserver(handler); + ro.observe(el); + return () => { + el.removeEventListener('scroll', handler); + ro.disconnect(); + }; + }, [openFiles.length]); + + const [childrenByDir, setChildrenByDir] = React.useState>({}); + const [loadErrorsByDir, setLoadErrorsByDir] = React.useState>({}); + const loadedDirsRef = React.useRef>(new Set()); + const inFlightDirsRef = React.useRef>(new Set()); + const activeDirectoryLoadIdsRef = React.useRef>(new Map()); + const nextDirectoryLoadIdRef = React.useRef(0); + + const [searchResults, setSearchResults] = React.useState([]); + const [searching, setSearching] = React.useState(false); + + const [fileContent, setFileContent] = React.useState(''); + const [fileLoading, setFileLoading] = React.useState(false); + const [fileError, setFileError] = React.useState(null); + const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); + const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState(''); + + const [loadedFilePath, setLoadedFilePath] = React.useState(null); + + const [draftContent, setDraftContent] = React.useState(''); + const [isSaving, setIsSaving] = React.useState(false); + const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState('\n'); const dialogInputRef = React.useRef(null); const autoSaveTimerRef = React.useRef | null>(null); - const lastLoadedFileStatRef = React.useRef(null); - const activeFileLoadIdRef = React.useRef(0); - const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle'); - const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled); - - const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); - const pendingSelectFileRef = React.useRef(null); - const pendingTabRef = React.useRef(null); - const pendingClosePathRef = React.useRef(null); - const skipDirtyOnceRef = React.useRef(false); - const copiedContentTimeoutRef = React.useRef(null); - const copiedPathTimeoutRef = React.useRef(null); - const editorViewRef = React.useRef(null); - const editorWrapperRef = React.useRef(null); - const [editorViewReadyNonce, setEditorViewReadyNonce] = React.useState(0); - const pendingNavigationRafRef = React.useRef(null); - const pendingNavigationCycleRef = React.useRef<{ key: string; attempts: number }>({ key: '', attempts: 0 }); - - React.useEffect(() => { - return () => { - if (pendingNavigationRafRef.current !== null && typeof window !== 'undefined') { - window.cancelAnimationFrame(pendingNavigationRafRef.current); - pendingNavigationRafRef.current = null; - } - }; - }, []); - - const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null); - const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null); - const [dialogInputValue, setDialogInputValue] = React.useState(''); - const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false); - const [contextMenuPath, setContextMenuPath] = React.useState(null); - const [copiedContent, setCopiedContent] = React.useState(false); - const [copiedPath, setCopiedPath] = React.useState(false); - const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false); - - const canCreateFile = Boolean(files.writeFile); - const canCreateFolder = Boolean(files.createDirectory); - const canRename = Boolean(files.rename); - const canDelete = Boolean(files.delete); - const canReveal = Boolean(files.revealPath); - const openInApps = useOpenInAppsStore((state) => state.availableApps); - const openInCacheStale = useOpenInAppsStore((state) => state.isCacheStale); - const initializeOpenInApps = useOpenInAppsStore((state) => state.initialize); - const loadOpenInApps = useOpenInAppsStore((state) => state.loadInstalledApps); - - React.useEffect(() => { - initializeOpenInApps(); - }, [initializeOpenInApps]); - - const handleRevealPath = React.useCallback((targetPath: string) => { - if (!files.revealPath) return; - void files.revealPath(targetPath).catch(() => { - toast.error(t('sidebarFilesTree.toast.revealFailed')); - }); - }, [files, t]); - - const handleOpenInApp = React.useCallback(async (app: { id: string; appName: string }) => { - if (!selectedFile?.path) { - return; - } - - const openedInApp = await openDesktopFileInApp(selectedFile.path, app.id, app.appName); - if (openedInApp) { - return; - } - - const openedFile = await openDesktopPath(selectedFile.path, app.appName); - if (openedFile) { - return; - } - - const fileDirectory = getParentDirectoryPath(selectedFile.path) || root; - if (fileDirectory) { - const openedDirectory = await openDesktopPath(fileDirectory, app.appName); - if (openedDirectory) { - return; - } - } - toast.error(t('filesView.toast.openInAppFailed', { app: app.appName })); - }, [root, selectedFile?.path, t]); - - const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { - setActiveDialog(type); - setDialogData(data); - setDialogInputValue(type === 'rename' ? data.name || '' : ''); - setIsDialogSubmitting(false); - }, []); - - // Line selection state for commenting - const [lineSelection, setLineSelection] = React.useState(null); - const isSelectingRef = React.useRef(false); - const selectionStartRef = React.useRef(null); - const [isDragging, setIsDragging] = React.useState(false); - - // Session/config for sending comments - const setMainTabGuard = useUIStore((state) => state.setMainTabGuard); - const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation); - const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); - const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); - const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); - const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); - - // Global mouseup to end drag selection - React.useEffect(() => { - const handleGlobalMouseUp = () => { - isSelectingRef.current = false; - selectionStartRef.current = null; - setIsDragging(false); - }; - document.addEventListener('mouseup', handleGlobalMouseUp); - return () => document.removeEventListener('mouseup', handleGlobalMouseUp); - }, []); - - React.useEffect(() => { - return () => { - if (copiedContentTimeoutRef.current !== null) { - window.clearTimeout(copiedContentTimeoutRef.current); - } - if (copiedPathTimeoutRef.current !== null) { - window.clearTimeout(copiedPathTimeoutRef.current); - } - }; - }, []); - - // Extract selected code - const extractSelectedCode = React.useCallback((content: string, range: SelectedLineRange): string => { - const lines = content.split('\n'); - const startLine = Math.max(1, range.start); - const endLine = Math.min(lines.length, range.end); - if (startLine > endLine) return ''; - return lines.slice(startLine - 1, endLine).join('\n'); - }, []); - - const fileCommentController = useInlineCommentController({ - source: 'file', - fileLabel: selectedFile?.path ?? null, - language: selectedFile?.path ? getLanguageFromExtension(selectedFile.path) || 'text' : 'text', - getCodeForRange: (range) => extractSelectedCode(fileContent, normalizeLineRange(range)), - toStoreRange: (range) => ({ startLine: range.start, endLine: range.end }), - fromDraftRange: (draft) => ({ start: draft.startLine, end: draft.endLine }), - }); - - const { - drafts: filesFileDrafts, - commentText, - editingDraftId, - setSelection: setCommentSelection, - saveComment, - cancel, - reset, - startEdit, - deleteDraft, - } = fileCommentController; - - React.useEffect(() => { - setLineSelection(null); - reset(); - setMainTabGuard(null); - setDraftContent(''); - setIsSaving(false); - }, [selectedFile?.path, reset, setMainTabGuard]); - - React.useEffect(() => { - setCommentSelection(lineSelection); - }, [lineSelection, setCommentSelection]); - - React.useEffect(() => { - if (!lineSelection && !editingDraftId) return; - - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as HTMLElement; - - if (target.closest('[data-comment-input="true"]') || target.closest('[data-comment-card="true"]')) return; - if (target.closest('.cm-gutterElement')) return; - if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; - - setLineSelection(null); - cancel(); - }; - - const timeoutId = setTimeout(() => { - document.addEventListener('click', handleClickOutside); - }, 100); - - return () => { - clearTimeout(timeoutId); - document.removeEventListener('click', handleClickOutside); - }; - }, [cancel, editingDraftId, lineSelection]); - - const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => { - const finalRange = range ?? lineSelection ?? undefined; - if (range) { - setLineSelection(range); - } - saveComment(text, finalRange); - setLineSelection(null); - }, [lineSelection, saveComment]); - - const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => { - const nodes: FileNode[] = []; - for (const entry of entries) { - if (!(entry && typeof entry.name === 'string' && entry.name.length > 0)) continue; - if (!showHidden && entry.name.startsWith('.')) continue; - if (!showGitignored && shouldIgnoreEntryName(entry.name)) continue; - const name = entry.name; - const normalizedEntryPath = normalizePath(entry.path || ''); - const path = normalizedEntryPath - ? (isAbsolutePath(normalizedEntryPath) - ? normalizedEntryPath - : normalizePath(`${dirPath}/${normalizedEntryPath}`)) - : normalizePath(`${dirPath}/${name}`); - const type = entry.isDirectory ? 'directory' : 'file'; - const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; - nodes.push({ name, path, type, extension }); - } - - return sortNodes(nodes); - }, [showGitignored, showHidden]); - - const loadDirectory = React.useCallback(async (dirPath: string) => { - const normalizedDir = normalizePath(dirPath.trim()); - if (!normalizedDir) { - return; - } - - if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) { - return; - } - - inFlightDirsRef.current = new Set(inFlightDirsRef.current); - inFlightDirsRef.current.add(normalizedDir); - const requestId = nextDirectoryLoadIdRef.current + 1; - nextDirectoryLoadIdRef.current = requestId; - activeDirectoryLoadIdsRef.current = new Map(activeDirectoryLoadIdsRef.current); - activeDirectoryLoadIdsRef.current.set(normalizedDir, requestId); - - const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId; - - const listPromise = files.listDirectory - ? files.listDirectory(normalizedDir).then((result) => result.entries.map((entry) => ({ - name: entry.name, - path: entry.path, - isDirectory: entry.isDirectory, - }))) - : opencodeClient.listLocalDirectory(normalizedDir).then((result) => result.map((entry) => ({ - name: entry.name, - path: entry.path, - isDirectory: entry.isDirectory, - }))); - - await listPromise - .then((entries) => { - if (!isCurrentRequest()) { - return; - } - - const mapped = mapDirectoryEntries(normalizedDir, entries); - - loadedDirsRef.current = new Set(loadedDirsRef.current); - loadedDirsRef.current.add(normalizedDir); - setLoadErrorsByDir((prev) => { - if (!prev[normalizedDir]) return prev; - const next = { ...prev }; - delete next[normalizedDir]; - return next; - }); - setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); - }) - .catch((error) => { - if (!isCurrentRequest()) { - return; - } - - const message = error instanceof Error ? error.message : String(error ?? ''); - console.error('Failed to load files directory:', error); - setLoadErrorsByDir((prev) => ({ - ...prev, - [normalizedDir]: message, - })); - }) - .finally(() => { - if (!isCurrentRequest()) { - return; - } - - activeDirectoryLoadIdsRef.current = new Map(activeDirectoryLoadIdsRef.current); - activeDirectoryLoadIdsRef.current.delete(normalizedDir); - inFlightDirsRef.current = new Set(inFlightDirsRef.current); - inFlightDirsRef.current.delete(normalizedDir); - }); - }, [files, mapDirectoryEntries]); - - const refreshRoot = React.useCallback(async () => { - if (!root) { - return; - } - - loadedDirsRef.current = new Set(); - inFlightDirsRef.current = new Set(); - activeDirectoryLoadIdsRef.current = new Map(); - setLoadErrorsByDir({}); - setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); - - await loadDirectory(root); - }, [loadDirectory, root]); - - /** - * Incrementally refresh a single directory without nuking the rest of the - * tree. After the operation the parent directory is reloaded in-place so - * the new/renamed/deleted entry becomes visible immediately while every - * other expanded directory keeps its cached children. - */ - const refreshDirectory = React.useCallback(async (dirPath: string) => { - if (!dirPath) { - await refreshRoot(); - return; - } - const normalized = normalizePath(dirPath); - // Remove from loaded set so loadDirectory will actually fetch again. - loadedDirsRef.current = new Set(loadedDirsRef.current); - loadedDirsRef.current.delete(normalized); - // Also cancel any in-flight request for this dir so the new fetch wins. - inFlightDirsRef.current = new Set(inFlightDirsRef.current); - inFlightDirsRef.current.delete(normalized); - await loadDirectory(normalized); - }, [loadDirectory, refreshRoot]); - - const lastFilesViewDirRef = React.useRef(''); - const lastFilesViewTreeKeyRef = React.useRef(''); - - React.useEffect(() => { - if (!root) { - return; - } - - const treeKey = `${root}|h${showHidden ? '1' : '0'}|g${showGitignored ? '1' : '0'}`; - const dirChanged = lastFilesViewDirRef.current !== root; - const treeKeyChanged = lastFilesViewTreeKeyRef.current !== treeKey; - - if (!dirChanged && !treeKeyChanged) { - return; - } - - if (dirChanged) { - lastFilesViewDirRef.current = root; - setFileContent(''); - setFileError(null); - setDesktopImageSrc(''); - setLoadedFilePath(null); - setShowMobilePageContent(false); - } - - if (treeKeyChanged) { - lastFilesViewTreeKeyRef.current = treeKey; - loadedDirsRef.current = new Set(); - inFlightDirsRef.current = new Set(); - activeDirectoryLoadIdsRef.current = new Map(); - setLoadErrorsByDir({}); - setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); - void loadDirectory(root); - } - }, [loadDirectory, root, showGitignored, showHidden]); - - // Auto-refresh expanded directories when user returns to the tab - React.useEffect(() => { - if (!files.listDirectory) return; - - const handleVisibilityChange = () => { - if (!document.hidden && expandedPaths.length > 0) { - for (const dir of expandedPaths) { - void refreshDirectory(dir); - } - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => document.removeEventListener('visibilitychange', handleVisibilityChange); - }, [expandedPaths, files.listDirectory, refreshDirectory]); - - // Poll expanded directories for external changes - React.useEffect(() => { - if (!files.listDirectory) return; - if (expandedPaths.length === 0) return; - - const interval = setInterval(() => { - if (document.hidden) return; - for (const dir of expandedPaths) { - void refreshDirectory(dir); - } - }, 8000); - - return () => clearInterval(interval); - }, [expandedPaths, files.listDirectory, refreshDirectory]); - - const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => { - e?.preventDefault(); - if (!dialogData || !activeDialog) return; - - setIsDialogSubmitting(true); - const finishDialogOperation = () => { - setActiveDialog(null); - }; - - const failDialogOperation = (message: string) => { - toast.error(message); - }; - - const done = () => { - setIsDialogSubmitting(false); - }; - - if (activeDialog === 'createFile') { - if (!dialogInputValue.trim()) { - failDialogOperation(t('sidebarFilesTree.toast.filenameRequired')); - done(); - return; - } - if (!files.writeFile) { - failDialogOperation(t('sidebarFilesTree.toast.writeNotSupported')); - done(); - return; - } - - const parentPath = dialogData.path; - const prefix = parentPath ? `${parentPath}/` : ''; - const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); - await files.writeFile(newPath, '') - .then(async (result) => { - if (result.success) { - toast.success(t('sidebarFilesTree.toast.fileCreated')); - await refreshDirectory(parentPath); - } - finishDialogOperation(); - }) - .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) - .finally(done); - return; - } - - if (activeDialog === 'createFolder') { - if (!dialogInputValue.trim()) { - failDialogOperation(t('sidebarFilesTree.toast.folderNameRequired')); - done(); - return; - } - - const parentPath = dialogData.path; - const prefix = parentPath ? `${parentPath}/` : ''; - const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); - await files.createDirectory(newPath) - .then(async (result) => { - if (result.success) { - toast.success(t('sidebarFilesTree.toast.folderCreated')); - await refreshDirectory(parentPath); - } - finishDialogOperation(); - }) - .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) - .finally(done); - return; - } - - if (activeDialog === 'rename') { - if (!dialogInputValue.trim()) { - failDialogOperation(t('sidebarFilesTree.toast.nameRequired')); - done(); - return; - } - - if (!files.rename) { - failDialogOperation(t('sidebarFilesTree.toast.renameNotSupported')); - done(); - return; - } - - const oldPath = dialogData.path; - const parentDir = oldPath.split('/').slice(0, -1).join('/'); - const prefix = parentDir ? `${parentDir}/` : ''; - const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); - - await files.rename(oldPath, newPath) - .then(async (result) => { - if (result.success) { - toast.success(t('sidebarFilesTree.toast.renamedSuccessfully')); - await refreshDirectory(parentDir); - if (root) { - removeOpenPathsByPrefix(root, oldPath); - } - if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) { - if (root) { - setSelectedPath(root, null); - } - setFileContent(''); - setFileError(null); - setDesktopImageSrc(''); - setLoadedFilePath(null); - if (isMobile) { - setShowMobilePageContent(false); - } - } - } - finishDialogOperation(); - }) - .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) - .finally(done); - return; - } - - if (activeDialog === 'delete') { - if (!files.delete) { - failDialogOperation(t('sidebarFilesTree.toast.deleteNotSupported')); - done(); - return; - } - - const deletedPath = dialogData.path; - const parentDir = deletedPath.split('/').slice(0, -1).join('/'); - await files.delete(deletedPath) - .then(async (result) => { - if (result.success) { - toast.success(t('sidebarFilesTree.toast.deletedSuccessfully')); - await refreshDirectory(parentDir); - if (root) { - removeOpenPathsByPrefix(root, deletedPath); - } - if (selectedFile?.path === deletedPath || selectedFile?.path.startsWith(`${deletedPath}/`)) { - if (root) { - setSelectedPath(root, null); - } - setFileContent(''); - setFileError(null); - setDesktopImageSrc(''); - setLoadedFilePath(null); - if (isMobile) { - setShowMobilePageContent(false); - } - } - } - finishDialogOperation(); - }) - .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) - .finally(done); - return; - } - - done(); - }, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath, t]); - - React.useEffect(() => { - if (!currentDirectory) { - setSearchResults([]); - setSearching(false); - return; - } - - const trimmedQuery = debouncedSearchQuery.trim(); - if (!trimmedQuery) { - setSearchResults([]); - setSearching(false); - return; - } - - let cancelled = false; - setSearching(true); - - searchFiles(currentDirectory, trimmedQuery, 150, { - includeHidden: showHidden, - respectGitignore: !showGitignored, - type: 'file', - }) - .then((hits) => { - if (cancelled) { - return; - } - - const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path)); - - const mapped: FileNode[] = filtered.map((hit) => ({ - name: hit.name, - path: normalizePath(hit.path), - type: 'file', - extension: hit.extension, - relativePath: hit.relativePath, - })); - - setSearchResults(mapped); - }) - .catch(() => { - if (!cancelled) { - setSearchResults([]); - } - }) - .finally(() => { - if (!cancelled) { - setSearching(false); - } - }); - - return () => { - cancelled = true; - }; - }, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); - - const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; optional?: boolean }): Promise => { - if (files.readFile) { - const result = await files.readFile(path, options); - return result.content ?? ''; - } - - const params = new URLSearchParams({ path }); - if (options?.allowOutsideWorkspace) { - params.set('allowOutsideWorkspace', 'true'); - } - if (options?.optional) { - params.set('optional', 'true'); - } - const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { - // Avoid conditional requests (304 + empty body). - cache: options?.optional ? 'no-store' : 'default', - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed')); - } - return response.text(); - }, [files, t]); - - const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise => { - if (files.statFile) { - const result = await files.statFile(path, options); - return { - path: result.path, - size: result.size, - mtimeMs: result.mtimeMs, - }; - } - return null; - }, [files]); - - React.useEffect(() => { - if (!root || !files.statFile || openPaths.length === 0) { - return; - } - - let cancelled = false; - const paths = [...openPaths]; - - void Promise.all(paths.map(async (path) => { - try { - const stat = await files.statFile?.(path); - if (!cancelled && stat && !stat.isFile) { - removeOpenPathsByPrefix(root, path); - } - } catch (error) { - if (!cancelled && isFileMissingError(error)) { - removeOpenPathsByPrefix(root, path); - } - } - })); - - return () => { - cancelled = true; - }; - }, [files, openPaths, removeOpenPathsByPrefix, root]); - - const displayedContent = React.useMemo(() => - fileContent.length > MAX_VIEW_CHARS - ? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` - : fileContent, - [fileContent] - ); - - const isDirty = draftContent !== displayedContent; - - const saveDraft = React.useCallback(async () => { - if (!selectedFile || !files.writeFile) { - toast.error(t('filesView.toast.savingNotSupported')); + const diagramAutoSaveTimerRef = React.useRef | null>(null); + const diagramXmlRef = React.useRef(''); + const diagramSavedXmlRef = React.useRef(''); + const pendingDrawioPreviewFrameRef = React.useRef(null); + const diagramEditorRef = React.useRef>(null); + const lastLoadedFileStatRef = React.useRef(null); + const activeFileLoadIdRef = React.useRef(0); + const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle'); + const [diagramSaved, setDiagramSaved] = React.useState(false); + const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled); + + const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); + const pendingSelectFileRef = React.useRef(null); + const pendingTabRef = React.useRef(null); + const pendingClosePathRef = React.useRef(null); + const skipDirtyOnceRef = React.useRef(false); + const copiedContentTimeoutRef = React.useRef(null); + const copiedPathTimeoutRef = React.useRef(null); + const editorViewRef = React.useRef(null); + const editorWrapperRef = React.useRef(null); + const [editorViewReadyNonce, setEditorViewReadyNonce] = React.useState(0); + const pendingNavigationRafRef = React.useRef(null); + const pendingNavigationCycleRef = React.useRef<{ key: string; attempts: number }>({ key: '', attempts: 0 }); + + React.useEffect(() => { + return () => { + if (pendingNavigationRafRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(pendingNavigationRafRef.current); + pendingNavigationRafRef.current = null; + } + }; + }, []); + + const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null); + const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null); + const [dialogInputValue, setDialogInputValue] = React.useState(''); + const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false); + const [contextMenuPath, setContextMenuPath] = React.useState(null); + const [copiedContent, setCopiedContent] = React.useState(false); + const [copiedPath, setCopiedPath] = React.useState(false); + const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false); + + const canCreateFile = Boolean(files.writeFile); + const canCreateFolder = Boolean(files.createDirectory); + const canRename = Boolean(files.rename); + const canDelete = Boolean(files.delete); + const canReveal = Boolean(files.revealPath); + const openInApps = useOpenInAppsStore((state) => state.availableApps); + const openInCacheStale = useOpenInAppsStore((state) => state.isCacheStale); + const initializeOpenInApps = useOpenInAppsStore((state) => state.initialize); + const loadOpenInApps = useOpenInAppsStore((state) => state.loadInstalledApps); + + React.useEffect(() => { + initializeOpenInApps(); + }, [initializeOpenInApps]); + + const handleRevealPath = React.useCallback((targetPath: string) => { + if (!files.revealPath) return; + void files.revealPath(targetPath).catch(() => { + toast.error(t('sidebarFilesTree.toast.revealFailed')); + }); + }, [files, t]); + + const handleOpenInApp = React.useCallback(async (app: { id: string; appName: string }) => { + if (!selectedFile?.path) { + return; + } + + const openedInApp = await openDesktopFileInApp(selectedFile.path, app.id, app.appName); + if (openedInApp) { + return; + } + + const openedFile = await openDesktopPath(selectedFile.path, app.appName); + if (openedFile) { + return; + } + + const fileDirectory = getParentDirectoryPath(selectedFile.path) || root; + if (fileDirectory) { + const openedDirectory = await openDesktopPath(fileDirectory, app.appName); + if (openedDirectory) { + return; + } + } + toast.error(t('filesView.toast.openInAppFailed', { app: app.appName })); + }, [root, selectedFile?.path, t]); + + const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { + setActiveDialog(type); + setDialogData(data); + setDialogInputValue(type === 'rename' ? data.name || '' : ''); + setIsDialogSubmitting(false); + }, []); + + // Line selection state for commenting + const [lineSelection, setLineSelection] = React.useState(null); + const isSelectingRef = React.useRef(false); + const selectionStartRef = React.useRef(null); + const [isDragging, setIsDragging] = React.useState(false); + + // Session/config for sending comments + const setMainTabGuard = useUIStore((state) => state.setMainTabGuard); + const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation); + const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); + const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); + const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); + const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); + const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); + + // Global mouseup to end drag selection + React.useEffect(() => { + const handleGlobalMouseUp = () => { + isSelectingRef.current = false; + selectionStartRef.current = null; + setIsDragging(false); + }; + document.addEventListener('mouseup', handleGlobalMouseUp); + return () => document.removeEventListener('mouseup', handleGlobalMouseUp); + }, []); + + React.useEffect(() => { + return () => { + if (copiedContentTimeoutRef.current !== null) { + window.clearTimeout(copiedContentTimeoutRef.current); + } + if (copiedPathTimeoutRef.current !== null) { + window.clearTimeout(copiedPathTimeoutRef.current); + } + }; + }, []); + + // Extract selected code + const extractSelectedCode = React.useCallback((content: string, range: SelectedLineRange): string => { + const lines = content.split('\n'); + const startLine = Math.max(1, range.start); + const endLine = Math.min(lines.length, range.end); + if (startLine > endLine) return ''; + return lines.slice(startLine - 1, endLine).join('\n'); + }, []); + + const fileCommentController = useInlineCommentController({ + source: 'file', + fileLabel: selectedFile?.path ?? null, + language: selectedFile?.path ? getLanguageFromExtension(selectedFile.path) || 'text' : 'text', + getCodeForRange: (range) => extractSelectedCode(fileContent, normalizeLineRange(range)), + toStoreRange: (range) => ({ startLine: range.start, endLine: range.end }), + fromDraftRange: (draft) => ({ start: draft.startLine, end: draft.endLine }), + }); + + const { + drafts: filesFileDrafts, + commentText, + editingDraftId, + setSelection: setCommentSelection, + saveComment, + cancel, + reset, + startEdit, + deleteDraft, + } = fileCommentController; + + React.useEffect(() => { + setLineSelection(null); + reset(); + setMainTabGuard(null); + setDraftContent(''); + setIsSaving(false); + }, [selectedFile?.path, reset, setMainTabGuard]); + + React.useEffect(() => { + setCommentSelection(lineSelection); + }, [lineSelection, setCommentSelection]); + + React.useEffect(() => { + if (!lineSelection && !editingDraftId) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + if (target.closest('[data-comment-input="true"]') || target.closest('[data-comment-card="true"]')) return; + if (target.closest('.cm-gutterElement')) return; + if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; + + setLineSelection(null); + cancel(); + }; + + const timeoutId = setTimeout(() => { + document.addEventListener('click', handleClickOutside); + }, 100); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('click', handleClickOutside); + }; + }, [cancel, editingDraftId, lineSelection]); + + const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => { + const finalRange = range ?? lineSelection ?? undefined; + if (range) { + setLineSelection(range); + } + saveComment(text, finalRange); + setLineSelection(null); + }, [lineSelection, saveComment]); + + const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => { + const nodes: FileNode[] = []; + for (const entry of entries) { + if (!(entry && typeof entry.name === 'string' && entry.name.length > 0)) continue; + if (!showHidden && entry.name.startsWith('.')) continue; + if (!showGitignored && shouldIgnoreEntryName(entry.name)) continue; + const name = entry.name; + const normalizedEntryPath = normalizePath(entry.path || ''); + const path = normalizedEntryPath + ? (isAbsolutePath(normalizedEntryPath) + ? normalizedEntryPath + : normalizePath(`${dirPath}/${normalizedEntryPath}`)) + : normalizePath(`${dirPath}/${name}`); + const type = entry.isDirectory ? 'directory' : 'file'; + const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; + nodes.push({ name, path, type, extension }); + } + + return sortNodes(nodes); + }, [showGitignored, showHidden]); + + const loadDirectory = React.useCallback(async (dirPath: string) => { + const normalizedDir = normalizePath(dirPath.trim()); + if (!normalizedDir) { + return; + } + + if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) { + return; + } + + inFlightDirsRef.current = new Set(inFlightDirsRef.current); + inFlightDirsRef.current.add(normalizedDir); + const requestId = nextDirectoryLoadIdRef.current + 1; + nextDirectoryLoadIdRef.current = requestId; + activeDirectoryLoadIdsRef.current = new Map(activeDirectoryLoadIdsRef.current); + activeDirectoryLoadIdsRef.current.set(normalizedDir, requestId); + + const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId; + + const listPromise = files.listDirectory + ? files.listDirectory(normalizedDir).then((result) => result.entries.map((entry) => ({ + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + }))) + : opencodeClient.listLocalDirectory(normalizedDir).then((result) => result.map((entry) => ({ + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + }))); + + await listPromise + .then((entries) => { + if (!isCurrentRequest()) { + return; + } + + const mapped = mapDirectoryEntries(normalizedDir, entries); + + loadedDirsRef.current = new Set(loadedDirsRef.current); + loadedDirsRef.current.add(normalizedDir); + setLoadErrorsByDir((prev) => { + if (!prev[normalizedDir]) return prev; + const next = { ...prev }; + delete next[normalizedDir]; + return next; + }); + setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); + }) + .catch((error) => { + if (!isCurrentRequest()) { + return; + } + + const message = error instanceof Error ? error.message : String(error ?? ''); + console.error('Failed to load files directory:', error); + setLoadErrorsByDir((prev) => ({ + ...prev, + [normalizedDir]: message, + })); + }) + .finally(() => { + if (!isCurrentRequest()) { + return; + } + + activeDirectoryLoadIdsRef.current = new Map(activeDirectoryLoadIdsRef.current); + activeDirectoryLoadIdsRef.current.delete(normalizedDir); + inFlightDirsRef.current = new Set(inFlightDirsRef.current); + inFlightDirsRef.current.delete(normalizedDir); + }); + }, [files, mapDirectoryEntries]); + + const refreshRoot = React.useCallback(async () => { + if (!root) { + return; + } + + loadedDirsRef.current = new Set(); + inFlightDirsRef.current = new Set(); + activeDirectoryLoadIdsRef.current = new Map(); + setLoadErrorsByDir({}); + setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); + + await loadDirectory(root); + }, [loadDirectory, root]); + + /** + * Incrementally refresh a single directory without nuking the rest of the + * tree. After the operation the parent directory is reloaded in-place so + * the new/renamed/deleted entry becomes visible immediately while every + * other expanded directory keeps its cached children. + */ + const refreshDirectory = React.useCallback(async (dirPath: string) => { + if (!dirPath) { + await refreshRoot(); + return; + } + const normalized = normalizePath(dirPath); + // Remove from loaded set so loadDirectory will actually fetch again. + loadedDirsRef.current = new Set(loadedDirsRef.current); + loadedDirsRef.current.delete(normalized); + // Also cancel any in-flight request for this dir so the new fetch wins. + inFlightDirsRef.current = new Set(inFlightDirsRef.current); + inFlightDirsRef.current.delete(normalized); + await loadDirectory(normalized); + }, [loadDirectory, refreshRoot]); + + const lastFilesViewDirRef = React.useRef(''); + const lastFilesViewTreeKeyRef = React.useRef(''); + + React.useEffect(() => { + if (!root) { + return; + } + + const treeKey = `${root}|h${showHidden ? '1' : '0'}|g${showGitignored ? '1' : '0'}`; + const dirChanged = lastFilesViewDirRef.current !== root; + const treeKeyChanged = lastFilesViewTreeKeyRef.current !== treeKey; + + if (!dirChanged && !treeKeyChanged) { + return; + } + + if (dirChanged) { + lastFilesViewDirRef.current = root; + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + setShowMobilePageContent(false); + } + + if (treeKeyChanged) { + lastFilesViewTreeKeyRef.current = treeKey; + loadedDirsRef.current = new Set(); + inFlightDirsRef.current = new Set(); + activeDirectoryLoadIdsRef.current = new Map(); + setLoadErrorsByDir({}); + setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); + void loadDirectory(root); + } + }, [loadDirectory, root, showGitignored, showHidden]); + + // Auto-refresh expanded directories when user returns to the tab + React.useEffect(() => { + if (!files.listDirectory) return; + + const handleVisibilityChange = () => { + if (!document.hidden && expandedPaths.length > 0) { + for (const dir of expandedPaths) { + void refreshDirectory(dir); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [expandedPaths, files.listDirectory, refreshDirectory]); + + // Poll expanded directories for external changes + React.useEffect(() => { + if (!files.listDirectory) return; + if (expandedPaths.length === 0) return; + + const interval = setInterval(() => { + if (document.hidden) return; + for (const dir of expandedPaths) { + void refreshDirectory(dir); + } + }, 8000); + + return () => clearInterval(interval); + }, [expandedPaths, files.listDirectory, refreshDirectory]); + + const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!dialogData || !activeDialog) return; + + setIsDialogSubmitting(true); + const finishDialogOperation = () => { + setActiveDialog(null); + }; + + const failDialogOperation = (message: string) => { + toast.error(message); + }; + + const done = () => { + setIsDialogSubmitting(false); + }; + + if (activeDialog === 'createFile') { + if (!dialogInputValue.trim()) { + failDialogOperation(t('sidebarFilesTree.toast.filenameRequired')); + done(); + return; + } + if (!files.writeFile) { + failDialogOperation(t('sidebarFilesTree.toast.writeNotSupported')); + done(); + return; + } + + const parentPath = dialogData.path; + const prefix = parentPath ? `${parentPath}/` : ''; + const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); + await files.writeFile(newPath, '') + .then(async (result) => { + if (result.success) { + toast.success(t('sidebarFilesTree.toast.fileCreated')); + await refreshDirectory(parentPath); + } + finishDialogOperation(); + }) + .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) + .finally(done); + return; + } + + if (activeDialog === 'createFolder') { + if (!dialogInputValue.trim()) { + failDialogOperation(t('sidebarFilesTree.toast.folderNameRequired')); + done(); + return; + } + + const parentPath = dialogData.path; + const prefix = parentPath ? `${parentPath}/` : ''; + const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); + await files.createDirectory(newPath) + .then(async (result) => { + if (result.success) { + toast.success(t('sidebarFilesTree.toast.folderCreated')); + await refreshDirectory(parentPath); + } + finishDialogOperation(); + }) + .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) + .finally(done); + return; + } + + if (activeDialog === 'rename') { + if (!dialogInputValue.trim()) { + failDialogOperation(t('sidebarFilesTree.toast.nameRequired')); + done(); + return; + } + + if (!files.rename) { + failDialogOperation(t('sidebarFilesTree.toast.renameNotSupported')); + done(); + return; + } + + const oldPath = dialogData.path; + const parentDir = oldPath.split('/').slice(0, -1).join('/'); + const prefix = parentDir ? `${parentDir}/` : ''; + const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); + + await files.rename(oldPath, newPath) + .then(async (result) => { + if (result.success) { + toast.success(t('sidebarFilesTree.toast.renamedSuccessfully')); + await refreshDirectory(parentDir); + if (root) { + removeOpenPathsByPrefix(root, oldPath); + } + if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) { + if (root) { + setSelectedPath(root, null); + } + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(false); + } + } + } + finishDialogOperation(); + }) + .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) + .finally(done); + return; + } + + if (activeDialog === 'delete') { + if (!files.delete) { + failDialogOperation(t('sidebarFilesTree.toast.deleteNotSupported')); + done(); + return; + } + + const deletedPath = dialogData.path; + const parentDir = deletedPath.split('/').slice(0, -1).join('/'); + await files.delete(deletedPath) + .then(async (result) => { + if (result.success) { + toast.success(t('sidebarFilesTree.toast.deletedSuccessfully')); + await refreshDirectory(parentDir); + if (root) { + removeOpenPathsByPrefix(root, deletedPath); + } + if (selectedFile?.path === deletedPath || selectedFile?.path.startsWith(`${deletedPath}/`)) { + if (root) { + setSelectedPath(root, null); + } + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(false); + } + } + } + finishDialogOperation(); + }) + .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) + .finally(done); + return; + } + + done(); + }, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath, t]); + + React.useEffect(() => { + if (!currentDirectory) { + setSearchResults([]); + setSearching(false); + return; + } + + const trimmedQuery = debouncedSearchQuery.trim(); + if (!trimmedQuery) { + setSearchResults([]); + setSearching(false); + return; + } + + let cancelled = false; + setSearching(true); + + searchFiles(currentDirectory, trimmedQuery, 150, { + includeHidden: showHidden, + respectGitignore: !showGitignored, + type: 'file', + }) + .then((hits) => { + if (cancelled) { + return; + } + + const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path)); + + const mapped: FileNode[] = filtered.map((hit) => ({ + name: hit.name, + path: normalizePath(hit.path), + type: 'file', + extension: hit.extension, + relativePath: hit.relativePath, + })); + + setSearchResults(mapped); + }) + .catch(() => { + if (!cancelled) { + setSearchResults([]); + } + }) + .finally(() => { + if (!cancelled) { + setSearching(false); + } + }); + + return () => { + cancelled = true; + }; + }, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); + + const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; optional?: boolean }): Promise => { + if (files.readFile) { + const result = await files.readFile(path, options); + return result.content ?? ''; + } + + const params = new URLSearchParams({ path }); + if (options?.allowOutsideWorkspace) { + params.set('allowOutsideWorkspace', 'true'); + } + if (options?.optional) { + params.set('optional', 'true'); + } + const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { + // Avoid conditional requests (304 + empty body). + cache: options?.optional ? 'no-store' : 'default', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed')); + } + return response.text(); + }, [files, t]); + + const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise => { + if (files.statFile) { + const result = await files.statFile(path, options); + return { + path: result.path, + size: result.size, + mtimeMs: result.mtimeMs, + }; + } + return null; + }, [files]); + + React.useEffect(() => { + if (!root || !files.statFile || openPaths.length === 0) { + return; + } + + let cancelled = false; + const paths = [...openPaths]; + + void Promise.all(paths.map(async (path) => { + try { + const stat = await files.statFile?.(path); + if (!cancelled && stat && !stat.isFile) { + removeOpenPathsByPrefix(root, path); + } + } catch (error) { + if (!cancelled && isFileMissingError(error)) { + removeOpenPathsByPrefix(root, path); + } + } + })); + + return () => { + cancelled = true; + }; + }, [files, openPaths, removeOpenPathsByPrefix, root]); + + const displayedContent = React.useMemo(() => + fileContent.length > MAX_VIEW_CHARS + ? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` + : fileContent, + [fileContent] + ); + + const isDirty = draftContent !== displayedContent; + + const saveDraft = React.useCallback(async () => { + if (!selectedFile || !files.writeFile) { + toast.error(t('filesView.toast.savingNotSupported')); + return false; + } + + if (!isDirty) { + return true; + } + + if (draftContent === '' && fileContent !== '' && loadedFilePath !== selectedFile.path) { + console.warn( + `[saveDraft] refusing to save empty draft for "${selectedFile.path}" (${fileContent.length} bytes were expected). ` + + 'The file may have been read during a concurrent write (O_TRUNC race). ' + + 'Try again after content finishes loading if the save was intentional.', + ); return false; } - - if (!isDirty) { - return true; - } - - setIsSaving(true); - - try { - const contentToWrite = serializeEditorContent(draftContent, loadedFileLineEnding); - const result = await files.writeFile(selectedFile.path, contentToWrite); - if (!result?.success) { - toast.error(t('filesView.toast.writeFileFailed')); - return false; + + setIsSaving(true); + + try { + const contentToWrite = serializeEditorContent(draftContent, loadedFileLineEnding); + const result = await files.writeFile(selectedFile.path, contentToWrite); + if (!result?.success) { + toast.error(t('filesView.toast.writeFileFailed')); + return false; } setFileContent(draftContent); + if (selectedFile?.path && isDrawioFile(selectedFile.path)) { + diagramXmlRef.current = draftContent; + diagramSavedXmlRef.current = draftContent; + } // Refresh stat after write so polling doesn't see a stale metadata change. - void readFileStat(selectedFile.path) - .then((stat) => { - if (stat) { - lastLoadedFileStatRef.current = stat; - } - }) - .catch(() => {}); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed')); - return false; - } finally { - setIsSaving(false); - } - }, [draftContent, files, isDirty, loadedFileLineEnding, readFileStat, selectedFile, t]); - - React.useEffect(() => { - if (!isDirty) { - setMainTabGuard(null); - return; - } - - const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => { - if (skipDirtyOnceRef.current) { - skipDirtyOnceRef.current = false; - return true; - } - setConfirmDiscardOpen(true); - pendingTabRef.current = _nextTab; - return false; - }; - - setMainTabGuard(guard); - - return () => { - const currentGuard = useUIStore.getState().mainTabGuard; - if (currentGuard === guard) { - setMainTabGuard(null); - } - }; - }, [isDirty, setMainTabGuard]); - - React.useEffect(() => { - try { - window.localStorage.setItem(FILE_EDITOR_AUTO_SAVE_KEY, autoSaveEnabled ? 'true' : 'false'); - } catch { - // Ignore localStorage errors; the in-memory preference still applies. - } - }, [autoSaveEnabled]); - - React.useEffect(() => { - if (autoSaveEnabled) { - return; - } - - setAutoSaveStatus('idle'); - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current); - autoSaveTimerRef.current = null; - } - }, [autoSaveEnabled]); - - // Auto-save: debounce 1.5s after user stops typing - const AUTO_SAVE_DELAY = 1500; - - React.useEffect(() => { - const canWrite = Boolean(selectedFile && files.writeFile); - if (!autoSaveEnabled || !isDirty || !canWrite || isSaving) { - return; - } - - autoSaveTimerRef.current = setTimeout(() => { - void saveDraft().then((saved) => { - if (!saved) return; - setAutoSaveStatus('saved'); - setTimeout(() => setAutoSaveStatus('idle'), 2000); - }); - }, AUTO_SAVE_DELAY); - - return () => { - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current); - autoSaveTimerRef.current = null; - } - }; - }, [autoSaveEnabled, draftContent, isDirty, selectedFile, files.writeFile, isSaving, saveDraft]); - - // Reset auto-save status when switching files - React.useEffect(() => { - setAutoSaveStatus('idle'); - }, [selectedFile?.path]); - - React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!hasModifier(e)) { - return; - } - - if (e.key.toLowerCase() === 's') { - e.preventDefault(); - // Cancel pending auto-save; user wants immediate save - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current); - autoSaveTimerRef.current = null; - } - if (!isSaving) { - void saveDraft().then((saved) => { - if (!saved) return; - setAutoSaveStatus('saved'); - setTimeout(() => setAutoSaveStatus('idle'), 2000); - }); - } - } else if (e.key.toLowerCase() === 'f') { - e.preventDefault(); - setIsSearchOpen(true); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isSaving, saveDraft]); - - const loadSelectedFile = React.useCallback(async (node: FileNode) => { - const loadId = activeFileLoadIdRef.current + 1; - activeFileLoadIdRef.current = loadId; - const isCurrentLoad = () => { - if (!root) return false; - const rootState = useFilesViewTabsStore.getState().byRoot[root]; - const currentPath = rootState?.selectedPath ?? rootState?.openPaths[0] ?? null; - return activeFileLoadIdRef.current === loadId && currentPath === node.path; - }; - - setFileError(null); - setDesktopImageSrc(''); - setLoadedFilePath(null); - - const selectedIsImage = isImageFile(node.path); - const isSvg = node.path.toLowerCase().endsWith('.svg'); - - if (isMobile) { - setShowMobilePageContent(true); - } - - // Desktop: binary images are loaded via readFileBinary (data URL). - if (runtime.isDesktop && selectedIsImage && !isSvg) { - setFileContent(''); - setDraftContent(''); - setFileLoading(true); - return; - } - - // Web: binary images should not be read as utf8. - if (!runtime.isDesktop && selectedIsImage && !isSvg) { - setFileContent(''); - setDraftContent(''); - setLoadedFilePath(node.path); - setFileLoading(false); - return; - } - - setFileLoading(true); - - const readOptions = { allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root) }; - - await readFile(node.path, readOptions) - .then((content) => { - if (!isCurrentLoad()) { - return; - } + void readFileStat(selectedFile.path) + .then((stat) => { + if (stat) { + lastLoadedFileStatRef.current = stat; + } + }) + .catch(() => {}); + return true; + } catch (error) { + toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed')); + return false; + } finally { + setIsSaving(false); + } + }, [draftContent, fileContent, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]); + + React.useEffect(() => { + if (!isDirty) { + setMainTabGuard(null); + return; + } + + const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => { + if (skipDirtyOnceRef.current) { + skipDirtyOnceRef.current = false; + return true; + } + setConfirmDiscardOpen(true); + pendingTabRef.current = _nextTab; + return false; + }; + + setMainTabGuard(guard); + + return () => { + const currentGuard = useUIStore.getState().mainTabGuard; + if (currentGuard === guard) { + setMainTabGuard(null); + } + }; + }, [isDirty, setMainTabGuard]); + + React.useEffect(() => { + try { + window.localStorage.setItem(FILE_EDITOR_AUTO_SAVE_KEY, autoSaveEnabled ? 'true' : 'false'); + } catch { + // Ignore localStorage errors; the in-memory preference still applies. + } + }, [autoSaveEnabled]); + + React.useEffect(() => { + if (autoSaveEnabled) { + return; + } + + setAutoSaveStatus('idle'); + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + }, [autoSaveEnabled]); + + // Auto-save: debounce 1.5s after user stops typing + const AUTO_SAVE_DELAY = 1500; + + React.useEffect(() => { + const canWrite = Boolean(selectedFile && files.writeFile); + if (!autoSaveEnabled || !isDirty || !canWrite || isSaving) { + return; + } + + autoSaveTimerRef.current = setTimeout(() => { + void saveDraft().then((saved) => { + if (!saved) return; + setAutoSaveStatus('saved'); + setTimeout(() => setAutoSaveStatus('idle'), 2000); + }); + }, AUTO_SAVE_DELAY); + + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + }; + }, [autoSaveEnabled, draftContent, isDirty, selectedFile, files.writeFile, isSaving, saveDraft]); + + // Reset auto-save status when switching files + React.useEffect(() => { + setAutoSaveStatus('idle'); + }, [selectedFile?.path]); + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!hasModifier(e)) { + return; + } + + if (e.key.toLowerCase() === 's') { + e.preventDefault(); + // Cancel pending auto-save; user wants immediate save + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + if (!isSaving) { + void saveDraft().then((saved) => { + if (!saved) return; + setAutoSaveStatus('saved'); + setTimeout(() => setAutoSaveStatus('idle'), 2000); + }); + } + } else if (e.key.toLowerCase() === 'f') { + e.preventDefault(); + setIsSearchOpen(true); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isSaving, saveDraft]); + + const loadSelectedFile = React.useCallback(async (node: FileNode) => { + const loadId = activeFileLoadIdRef.current + 1; + activeFileLoadIdRef.current = loadId; + const isCurrentLoad = () => { + if (!root) return false; + const rootState = useFilesViewTabsStore.getState().byRoot[root]; + const currentPath = rootState?.selectedPath ?? rootState?.openPaths[0] ?? null; + return activeFileLoadIdRef.current === loadId && currentPath === node.path; + }; + + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + + const selectedIsImage = isImageFile(node.path); + const isSvg = node.path.toLowerCase().endsWith('.svg'); + + if (isMobile) { + setShowMobilePageContent(true); + } + + // Desktop: binary images are loaded via readFileBinary (data URL). + if (runtime.isDesktop && selectedIsImage && !isSvg) { + setFileContent(''); + setDraftContent(''); + setFileLoading(true); + return; + } + + // Web: binary images should not be read as utf8. + if (!runtime.isDesktop && selectedIsImage && !isSvg) { + setFileContent(''); + setDraftContent(''); + setLoadedFilePath(node.path); + setFileLoading(false); + return; + } + + setFileLoading(true); + + const readOptions = { allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root) }; + + await readFile(node.path, readOptions) + .then((content) => { + if (!isCurrentLoad()) { + return; + } const editorContent = normalizeEditorLineEndings(content); setLoadedFileLineEnding(detectFileLineEnding(content)); setFileContent(editorContent); + diagramXmlRef.current = editorContent; + diagramSavedXmlRef.current = editorContent; setDraftContent(editorContent.length > MAX_VIEW_CHARS - ? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` - : editorContent); - setLoadedFilePath(node.path); - void readFileStat(node.path, readOptions) - .then((stat) => { - if (stat && isCurrentLoad()) { - lastLoadedFileStatRef.current = stat; - } - }) - .catch(() => {}); - }) - .catch((error) => { - if (!isCurrentLoad()) { - return; - } - if (isDirectoryReadError(error)) { - setFileLoading(false); - if (root) { - setSelectedPath(root, null); - } - setFileError(null); - setFileContent(''); - setDraftContent(''); - setLoadedFilePath(null); - lastLoadedFileStatRef.current = null; - if (searchQuery.trim().length > 0) { - setSearchQuery(''); - } - if (isMobile) { - setShowMobilePageContent(false); - } - if (root) { - const ancestors = getAncestorPaths(node.path, root); - const pathsToExpand = [...ancestors, node.path]; - if (pathsToExpand.length > 0) { - expandPaths(root, pathsToExpand); - } - for (const path of pathsToExpand) { - if (!loadedDirsRef.current.has(path)) { - void loadDirectory(path); - } - } - } - return; - } - if (isFileMissingError(error)) { - if (root) { - removeOpenPathsByPrefix(root, node.path); - } - setFileContent(''); - setDraftContent(''); - setFileError(null); - lastLoadedFileStatRef.current = null; - if (isMobile) { - setShowMobilePageContent(false); - } - return; - } - setFileContent(''); - setDraftContent(''); - setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); - lastLoadedFileStatRef.current = null; - }) - .finally(() => { - if (isCurrentLoad()) { - setFileLoading(false); - } - }); - }, [expandPaths, isMobile, loadDirectory, mode, readFile, readFileStat, removeOpenPathsByPrefix, root, runtime.isDesktop, searchQuery, setSelectedPath, t]); - - const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => { - if (!root) { - return; - } - - const ancestors = getAncestorPaths(targetPath, root); - const pathsToExpand = includeTarget ? [...ancestors, targetPath] : ancestors; - - if (pathsToExpand.length > 0) { - expandPaths(root, pathsToExpand); - } - - const loadPromises = pathsToExpand.map((path) => { - if (!loadedDirsRef.current.has(path)) { - return loadDirectory(path); - } - return undefined; - }).filter(Boolean); - await Promise.all(loadPromises); - }, [expandPaths, loadDirectory, root]); - - const getNextOpenFile = React.useCallback((path: string, filesList: FileNode[]) => { - const index = filesList.findIndex((file) => file.path === path); - if (index === -1 || filesList.length <= 1) { - return null; - } - return filesList[index + 1] ?? filesList[index - 1] ?? null; - }, []); - - const handleSelectFile = React.useCallback(async (node: FileNode) => { - if (skipDirtyOnceRef.current) { - skipDirtyOnceRef.current = false; - } else if (isDirty) { - setConfirmDiscardOpen(true); - pendingSelectFileRef.current = node; - return; - } - - if (root) { - setSelectedPath(root, node.path); - addOpenPath(root, node.path); - void ensurePathVisible(node.path, false); - } - + ? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` + : editorContent); + setLoadedFilePath(node.path); + void readFileStat(node.path, readOptions) + .then((stat) => { + if (stat && isCurrentLoad()) { + lastLoadedFileStatRef.current = stat; + } + }) + .catch(() => {}); + }) + .catch((error) => { + if (!isCurrentLoad()) { + return; + } + if (isDirectoryReadError(error)) { + setFileLoading(false); + if (root) { + setSelectedPath(root, null); + } + setFileError(null); + setFileContent(''); + setDraftContent(''); + setLoadedFilePath(null); + lastLoadedFileStatRef.current = null; + if (searchQuery.trim().length > 0) { + setSearchQuery(''); + } + if (isMobile) { + setShowMobilePageContent(false); + } + if (root) { + const ancestors = getAncestorPaths(node.path, root); + const pathsToExpand = [...ancestors, node.path]; + if (pathsToExpand.length > 0) { + expandPaths(root, pathsToExpand); + } + for (const path of pathsToExpand) { + if (!loadedDirsRef.current.has(path)) { + void loadDirectory(path); + } + } + } + return; + } + if (isFileMissingError(error)) { + if (root) { + removeOpenPathsByPrefix(root, node.path); + } + setFileContent(''); + setDraftContent(''); + setFileError(null); + lastLoadedFileStatRef.current = null; + if (isMobile) { + setShowMobilePageContent(false); + } + return; + } + setFileContent(''); + setDraftContent(''); + setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); + lastLoadedFileStatRef.current = null; + }) + .finally(() => { + if (isCurrentLoad()) { + setFileLoading(false); + } + }); + }, [expandPaths, isMobile, loadDirectory, mode, readFile, readFileStat, removeOpenPathsByPrefix, root, runtime.isDesktop, searchQuery, setSelectedPath, t]); + + const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => { + if (!root) { + return; + } + + const ancestors = getAncestorPaths(targetPath, root); + const pathsToExpand = includeTarget ? [...ancestors, targetPath] : ancestors; + + if (pathsToExpand.length > 0) { + expandPaths(root, pathsToExpand); + } + + const loadPromises = pathsToExpand.map((path) => { + if (!loadedDirsRef.current.has(path)) { + return loadDirectory(path); + } + return undefined; + }).filter(Boolean); + await Promise.all(loadPromises); + }, [expandPaths, loadDirectory, root]); + + const getNextOpenFile = React.useCallback((path: string, filesList: FileNode[]) => { + const index = filesList.findIndex((file) => file.path === path); + if (index === -1 || filesList.length <= 1) { + return null; + } + return filesList[index + 1] ?? filesList[index - 1] ?? null; + }, []); + + const handleSelectFile = React.useCallback(async (node: FileNode) => { + if (skipDirtyOnceRef.current) { + skipDirtyOnceRef.current = false; + } else if (isDirty) { + setConfirmDiscardOpen(true); + pendingSelectFileRef.current = node; + return; + } + + if (root) { + setSelectedPath(root, node.path); + addOpenPath(root, node.path); + void ensurePathVisible(node.path, false); + } + setFileError(null); setDesktopImageSrc(''); setFileContent(''); + diagramXmlRef.current = ''; + diagramSavedXmlRef.current = ''; setDraftContent(''); - setLoadedFilePath(null); - if (isMobile) { - setShowMobilePageContent(true); - } - }, [addOpenPath, ensurePathVisible, isDirty, isMobile, root, setSelectedPath]); - - React.useEffect(() => { - if (!selectedFile?.path) { - return; - } - - void ensurePathVisible(selectedFile.path, false); - }, [ensurePathVisible, selectedFile?.path]); - - React.useEffect(() => { - if (!selectedFile) { - activeFileLoadIdRef.current += 1; - setFileLoading(false); - return; - } - - if (loadedFilePath === selectedFile.path) { - return; - } - - // Selection changes are guarded; this effect is also what restores persisted tabs on mount. - void loadSelectedFile(selectedFile); - }, [loadSelectedFile, loadedFilePath, selectedFile]); - - // Sync isDirty to a ref so the polling interval can read the latest value - // without isDirty in its dependency array (avoids interval restart on every edit/save). - const isDirtyRef = React.useRef(isDirty); - isDirtyRef.current = isDirty; - - // Poll open file for external changes. - // When a change is detected, reset loadedFilePath so the effect above - // triggers a single reload — no double-load. - React.useEffect(() => { - if (!selectedFile?.path || loadedFilePath !== selectedFile.path) { - return; - } - - let cancelled = false; - const interval = window.setInterval(() => { - if (document.hidden) { - return; - } - - void readFileStat(selectedFile.path, selectedFileReadOptions) - .then((latestStat) => { - if (cancelled || !latestStat) { - return; - } - - const previousStat = lastLoadedFileStatRef.current; - if (!previousStat || previousStat.path !== selectedFile.path) { - lastLoadedFileStatRef.current = latestStat; - return; - } - - const changedByMtime = latestStat.mtimeMs !== undefined - && previousStat.mtimeMs !== undefined - && latestStat.mtimeMs !== previousStat.mtimeMs; - const changedBySize = latestStat.size !== previousStat.size; - - if (!changedByMtime && !changedBySize) { - return; - } - - if (isDirtyRef.current) { - return; - } - - lastLoadedFileStatRef.current = latestStat; - // Reset loadedFilePath so the effect above triggers a single reload. - setLoadedFilePath(null); - }) - .catch(() => {}); - }, 2000); - - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [loadedFilePath, readFileStat, selectedFile?.path, selectedFileReadOptions]); - - const discardAndContinue = React.useCallback(() => { - const nextFile = pendingSelectFileRef.current; - const nextTab = pendingTabRef.current; - const closePath = pendingClosePathRef.current; - - pendingSelectFileRef.current = null; - pendingTabRef.current = null; - pendingClosePathRef.current = null; - - // Allow one guarded navigation (tab/file) without re-opening dialog. - skipDirtyOnceRef.current = true; - - setConfirmDiscardOpen(false); - - // Discard draft by reverting back to last loaded content - setDraftContent(displayedContent); - - if (closePath) { - if (root) { - removeOpenPath(root, closePath); - } - if (selectedFile?.path === closePath) { - if (nextFile) { - void handleSelectFile(nextFile); - } else { - if (root) { - setSelectedPath(root, null); - } - setFileContent(''); - setFileError(null); - setDesktopImageSrc(''); - setLoadedFilePath(null); - if (isMobile) { - setShowMobilePageContent(false); - } - } - } - return; - } - - if (nextFile) { - void handleSelectFile(nextFile); - return; - } - - if (nextTab) { - setMainTabGuard(null); - useUIStore.getState().setActiveMainTab(nextTab); - } - }, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]); - - const saveAndContinue = React.useCallback(async () => { - const nextFile = pendingSelectFileRef.current; - const nextTab = pendingTabRef.current; - const closePath = pendingClosePathRef.current; - - const saved = await saveDraft(); - if (!saved) { - skipDirtyOnceRef.current = false; - return; - } - - pendingSelectFileRef.current = null; - pendingTabRef.current = null; - pendingClosePathRef.current = null; - - // We'll proceed after saving; suppress guard reopening. - skipDirtyOnceRef.current = true; - - setConfirmDiscardOpen(false); - - if (closePath) { - if (root) { - removeOpenPath(root, closePath); - } - if (selectedFile?.path === closePath) { - if (nextFile) { - await handleSelectFile(nextFile); - } else { - if (root) { - setSelectedPath(root, null); - } - setFileContent(''); - setFileError(null); - setDesktopImageSrc(''); - setLoadedFilePath(null); - if (isMobile) { - setShowMobilePageContent(false); - } - } - } - return; - } - - if (nextFile) { - await handleSelectFile(nextFile); - return; - } - - if (nextTab) { - setMainTabGuard(null); - useUIStore.getState().setActiveMainTab(nextTab); - } - }, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]); - - const handleCloseFile = React.useCallback((path: string) => { - const isActive = selectedFile?.path === path; - const nextFile = getNextOpenFile(path, openFiles); - - if (isActive && isDirty) { - setConfirmDiscardOpen(true); - pendingSelectFileRef.current = nextFile; - pendingClosePathRef.current = path; - return; - } - - if (root) { - removeOpenPath(root, path); - } - - if (!isActive) { - return; - } - - if (nextFile) { - void handleSelectFile(nextFile); - return; - } - - if (root) { - setSelectedPath(root, null); - } - setFileContent(''); - setFileError(null); - setDesktopImageSrc(''); - setLoadedFilePath(null); - if (isMobile) { - setShowMobilePageContent(false); - } - }, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, removeOpenPath, root, selectedFile?.path, setSelectedPath]); - - const getFileStatus = React.useCallback((path: string): FileStatus | null => { - // Check open status - if (openPaths.includes(path)) return 'open'; - - // Check git status - if (gitStatus?.files) { - const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path; - const file = gitStatus.files.find(f => f.path === relative); - if (file) { - if (file.index === 'A' || file.working_dir === '?') return 'git-added'; - if (file.index === 'D') return 'git-deleted'; - if (file.index === 'M' || file.working_dir === 'M') return 'git-modified'; - } - } - return null; - }, [openPaths, gitStatus, root]); - - const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => { - if (!gitStatus?.files) return null; - const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath; - const prefix = relativeDir ? `${relativeDir}/` : ''; - - let modified = 0, added = 0; - for (const f of gitStatus.files) { - if (f.path.startsWith(prefix)) { - if (f.index === 'M' || f.working_dir === 'M') modified++; - if (f.index === 'A' || f.working_dir === '?') added++; - } - } - return modified + added > 0 ? { modified, added } : null; - }, [gitStatus, root]); - - const toggleDirectory = React.useCallback(async (dirPath: string) => { - const normalized = normalizePath(dirPath); - if (!root) return; - - toggleExpandedPath(root, normalized); - - if (!loadedDirsRef.current.has(normalized)) { - await loadDirectory(normalized); - } - }, [loadDirectory, root, toggleExpandedPath]); - - const fileRowPermissions = React.useMemo( - () => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }), - [canRename, canCreateFile, canCreateFolder, canDelete, canReveal] - ); - - function renderTree(dirPath: string, depth: number): React.ReactNode { - const nodes = childrenByDir[dirPath] ?? []; - - return nodes.map((node, index) => { - const isDir = node.type === 'directory'; - const isExpanded = isDir && expandedPaths.includes(node.path); - const isActive = selectedFile?.path === node.path; - const isLast = index === nodes.length - 1; - - return ( -
  • - {depth > 0 && ( - <> - - {isLast && ( - - )} - - )} - - {isDir && isExpanded && ( -
      - {loadErrorsByDir[node.path] ? ( -
    • - {loadErrorsByDir[node.path]} - -
    • - ) : null} - {renderTree(node.path, depth + 1)} -
    - )} -
  • - ); - }); - } - - const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); - const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); - const pendingNavigationTargetPath = React.useMemo( - () => normalizePath(pendingFileNavigation?.path ?? ''), - [pendingFileNavigation?.path], - ); - const shouldMaskEditorForPendingNavigation = Boolean( - pendingFileNavigation - && pendingNavigationTargetPath - && selectedFilePath - && selectedFilePath === pendingNavigationTargetPath - && !fileLoading - && !fileError - && !isSelectedImage, - ); - - const displaySelectedPath = React.useMemo(() => { - return getDisplayPath(root, selectedFilePath); - }, [selectedFilePath, root]); - - const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0); - const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); - const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); - const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path)); - const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path)); - const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path)); - const isTextFile = Boolean(selectedFile && !isSelectedImage); - const canUseShikiFileView = isTextFile && !isMarkdown && !(isHtml && htmlViewMode === 'preview'); - const staticLanguageExtension = React.useMemo( - () => (selectedFilePath ? languageByExtension(selectedFilePath) : null), - [selectedFilePath], - ); - const [dynamicLanguageExtension, setDynamicLanguageExtension] = React.useState(null); - - React.useEffect(() => { - let cancelled = false; - const selectedPath = selectedFile?.path; - - if (!selectedPath || staticLanguageExtension) { - setDynamicLanguageExtension(null); - return; - } - - setDynamicLanguageExtension(null); - void loadLanguageByExtension(selectedPath).then((extension) => { - if (!cancelled) { - setDynamicLanguageExtension(extension); - } - }); - - return () => { - cancelled = true; - }; - }, [selectedFile?.path, staticLanguageExtension]); - - React.useEffect(() => { - if (!canEdit && textViewMode === 'edit') { - setTextViewMode('view'); - } - }, [canEdit, textViewMode]); - - const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode'; - const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode'; - const JSON_VIEWER_MODE_KEY = 'openchamber:files:json-viewer-mode'; - - React.useEffect(() => { - const selectedPath = selectedFile?.path; - if (!selectedPath) { - return; - } - - const defaultMode: TextViewMode = settingsDefaultFileViewerPreview ? 'view' : 'edit'; - setTextViewMode(textViewModeByPathRef.current[selectedPath] ?? defaultMode); - - // Respect per-type localStorage preference when available, - // falling back to the setting-derived default when nothing is stored. - let mdDefault: PreviewViewMode = settingsDefaultFileViewerPreview ? 'preview' : 'edit'; - try { - const stored = localStorage.getItem(MD_VIEWER_MODE_KEY); - if (stored === 'preview' || stored === 'edit') { - mdDefault = stored; - } - } catch { - // Ignore localStorage errors - } - setMdViewMode(mdViewModeByPathRef.current[selectedPath] ?? mdDefault); - - let htmlDefault: PreviewViewMode = settingsDefaultFileViewerPreview ? 'preview' : 'edit'; - try { - const stored = localStorage.getItem(HTML_VIEWER_MODE_KEY); - if (stored === 'preview' || stored === 'edit') { - htmlDefault = stored; - } - } catch { - // Ignore localStorage errors - } + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(true); + } + }, [addOpenPath, ensurePathVisible, isDirty, isMobile, root, setSelectedPath]); + + React.useEffect(() => { + if (!selectedFile?.path) { + return; + } + + void ensurePathVisible(selectedFile.path, false); + }, [ensurePathVisible, selectedFile?.path]); + + React.useEffect(() => { + if (!selectedFile) { + activeFileLoadIdRef.current += 1; + setFileLoading(false); + return; + } + + if (loadedFilePath === selectedFile.path) { + return; + } + + // Selection changes are guarded; this effect is also what restores persisted tabs on mount. + void loadSelectedFile(selectedFile); + }, [loadSelectedFile, loadedFilePath, selectedFile]); + + // Sync isDirty to a ref so the polling interval can read the latest value + // without isDirty in its dependency array (avoids interval restart on every edit/save). + const isDirtyRef = React.useRef(isDirty); + isDirtyRef.current = isDirty; + + // Poll open file for external changes. + // When a change is detected, reset loadedFilePath so the effect above + // triggers a single reload — no double-load. + React.useEffect(() => { + if (!selectedFile?.path || loadedFilePath !== selectedFile.path) { + return; + } + + let cancelled = false; + const interval = window.setInterval(() => { + if (document.hidden) { + return; + } + + void readFileStat(selectedFile.path, selectedFileReadOptions) + .then((latestStat) => { + if (cancelled || !latestStat) { + return; + } + + const previousStat = lastLoadedFileStatRef.current; + if (!previousStat || previousStat.path !== selectedFile.path) { + lastLoadedFileStatRef.current = latestStat; + return; + } + + const changedByMtime = latestStat.mtimeMs !== undefined + && previousStat.mtimeMs !== undefined + && latestStat.mtimeMs !== previousStat.mtimeMs; + const changedBySize = latestStat.size !== previousStat.size; + + if (!changedByMtime && !changedBySize) { + return; + } + + if (isDirtyRef.current) { + return; + } + + lastLoadedFileStatRef.current = latestStat; + // Reset loadedFilePath so the effect above triggers a single reload. + setLoadedFilePath(null); + }) + .catch(() => {}); + }, 2000); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [loadedFilePath, readFileStat, selectedFile?.path, selectedFileReadOptions]); + + const discardAndContinue = React.useCallback(() => { + const nextFile = pendingSelectFileRef.current; + const nextTab = pendingTabRef.current; + const closePath = pendingClosePathRef.current; + + pendingSelectFileRef.current = null; + pendingTabRef.current = null; + pendingClosePathRef.current = null; + + // Allow one guarded navigation (tab/file) without re-opening dialog. + skipDirtyOnceRef.current = true; + + setConfirmDiscardOpen(false); + + // Discard draft by reverting back to last loaded content + setDraftContent(displayedContent); + + if (closePath) { + if (root) { + removeOpenPath(root, closePath); + } + if (selectedFile?.path === closePath) { + if (nextFile) { + void handleSelectFile(nextFile); + } else { + if (root) { + setSelectedPath(root, null); + } + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(false); + } + } + } + return; + } + + if (nextFile) { + void handleSelectFile(nextFile); + return; + } + + if (nextTab) { + setMainTabGuard(null); + useUIStore.getState().setActiveMainTab(nextTab); + } + }, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]); + + const saveAndContinue = React.useCallback(async () => { + const nextFile = pendingSelectFileRef.current; + const nextTab = pendingTabRef.current; + const closePath = pendingClosePathRef.current; + + const saved = await saveDraft(); + if (!saved) { + skipDirtyOnceRef.current = false; + return; + } + + pendingSelectFileRef.current = null; + pendingTabRef.current = null; + pendingClosePathRef.current = null; + + // We'll proceed after saving; suppress guard reopening. + skipDirtyOnceRef.current = true; + + setConfirmDiscardOpen(false); + + if (closePath) { + if (root) { + removeOpenPath(root, closePath); + } + if (selectedFile?.path === closePath) { + if (nextFile) { + await handleSelectFile(nextFile); + } else { + if (root) { + setSelectedPath(root, null); + } + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(false); + } + } + } + return; + } + + if (nextFile) { + await handleSelectFile(nextFile); + return; + } + + if (nextTab) { + setMainTabGuard(null); + useUIStore.getState().setActiveMainTab(nextTab); + } + }, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]); + + const handleCloseFile = React.useCallback((path: string) => { + const isActive = selectedFile?.path === path; + const nextFile = getNextOpenFile(path, openFiles); + + if (isActive && isDirty) { + setConfirmDiscardOpen(true); + pendingSelectFileRef.current = nextFile; + pendingClosePathRef.current = path; + return; + } + + if (root) { + removeOpenPath(root, path); + } + + if (!isActive) { + return; + } + + if (nextFile) { + void handleSelectFile(nextFile); + return; + } + + if (root) { + setSelectedPath(root, null); + } + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(false); + } + }, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, removeOpenPath, root, selectedFile?.path, setSelectedPath]); + + const getFileStatus = React.useCallback((path: string): FileStatus | null => { + // Check open status + if (openPaths.includes(path)) return 'open'; + + // Check git status + if (gitStatus?.files) { + const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path; + const file = gitStatus.files.find(f => f.path === relative); + if (file) { + if (file.index === 'A' || file.working_dir === '?') return 'git-added'; + if (file.index === 'D') return 'git-deleted'; + if (file.index === 'M' || file.working_dir === 'M') return 'git-modified'; + } + } + return null; + }, [openPaths, gitStatus, root]); + + const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => { + if (!gitStatus?.files) return null; + const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath; + const prefix = relativeDir ? `${relativeDir}/` : ''; + + let modified = 0, added = 0; + for (const f of gitStatus.files) { + if (f.path.startsWith(prefix)) { + if (f.index === 'M' || f.working_dir === 'M') modified++; + if (f.index === 'A' || f.working_dir === '?') added++; + } + } + return modified + added > 0 ? { modified, added } : null; + }, [gitStatus, root]); + + const toggleDirectory = React.useCallback(async (dirPath: string) => { + const normalized = normalizePath(dirPath); + if (!root) return; + + toggleExpandedPath(root, normalized); + + if (!loadedDirsRef.current.has(normalized)) { + await loadDirectory(normalized); + } + }, [loadDirectory, root, toggleExpandedPath]); + + const fileRowPermissions = React.useMemo( + () => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }), + [canRename, canCreateFile, canCreateFolder, canDelete, canReveal] + ); + + function renderTree(dirPath: string, depth: number): React.ReactNode { + const nodes = childrenByDir[dirPath] ?? []; + + return nodes.map((node, index) => { + const isDir = node.type === 'directory'; + const isExpanded = isDir && expandedPaths.includes(node.path); + const isActive = selectedFile?.path === node.path; + const isLast = index === nodes.length - 1; + + return ( +
  • + {depth > 0 && ( + <> + + {isLast && ( + + )} + + )} + + {isDir && isExpanded && ( +
      + {loadErrorsByDir[node.path] ? ( +
    • + {loadErrorsByDir[node.path]} + +
    • + ) : null} + {renderTree(node.path, depth + 1)} +
    + )} +
  • + ); + }); + } + + const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); + const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); + const pendingNavigationTargetPath = React.useMemo( + () => normalizePath(pendingFileNavigation?.path ?? ''), + [pendingFileNavigation?.path], + ); + const shouldMaskEditorForPendingNavigation = Boolean( + pendingFileNavigation + && pendingNavigationTargetPath + && selectedFilePath + && selectedFilePath === pendingNavigationTargetPath + && !fileLoading + && !fileError + && !isSelectedImage, + ); + + const displaySelectedPath = React.useMemo(() => { + return getDisplayPath(root, selectedFilePath); + }, [selectedFilePath, root]); + + const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0); + const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); + const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); + const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path)); + const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path)); + const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path)); + const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path)); + const isTextFile = Boolean(selectedFile && !isSelectedImage); + const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview'); + const staticLanguageExtension = React.useMemo( + () => (selectedFilePath ? languageByExtension(selectedFilePath) : null), + [selectedFilePath], + ); + const [dynamicLanguageExtension, setDynamicLanguageExtension] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + const selectedPath = selectedFile?.path; + + if (!selectedPath || staticLanguageExtension) { + setDynamicLanguageExtension(null); + return; + } + + setDynamicLanguageExtension(null); + void loadLanguageByExtension(selectedPath).then((extension) => { + if (!cancelled) { + setDynamicLanguageExtension(extension); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedFile?.path, staticLanguageExtension]); + + React.useEffect(() => { + if (!canEdit && textViewMode === 'edit') { + setTextViewMode('view'); + } + }, [canEdit, textViewMode]); + + const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode'; + const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode'; + const JSON_VIEWER_MODE_KEY = 'openchamber:files:json-viewer-mode'; + + React.useEffect(() => { + const selectedPath = selectedFile?.path; + if (!selectedPath) { + return; + } + + const defaultMode: TextViewMode = settingsDefaultFileViewerPreview ? 'view' : 'edit'; + setTextViewMode(textViewModeByPathRef.current[selectedPath] ?? defaultMode); + + // Respect per-type localStorage preference when available, + // falling back to the setting-derived default when nothing is stored. + let mdDefault: PreviewViewMode = settingsDefaultFileViewerPreview ? 'preview' : 'edit'; + try { + const stored = localStorage.getItem(MD_VIEWER_MODE_KEY); + if (stored === 'preview' || stored === 'edit') { + mdDefault = stored; + } + } catch { + // Ignore localStorage errors + } + setMdViewMode(mdViewModeByPathRef.current[selectedPath] ?? mdDefault); + + let htmlDefault: PreviewViewMode = settingsDefaultFileViewerPreview ? 'preview' : 'edit'; + try { + const stored = localStorage.getItem(HTML_VIEWER_MODE_KEY); + if (stored === 'preview' || stored === 'edit') { + htmlDefault = stored; + } + } catch { + // Ignore localStorage errors + } setHtmlViewMode(htmlViewModeByPathRef.current[selectedPath] ?? htmlDefault); + setDrawioViewMode(drawioViewModeByPathRef.current[selectedPath] ?? 'preview'); let jsonDefault: 'tree' | 'text' = settingsDefaultFileViewerPreview ? 'tree' : 'text'; - try { - const stored = localStorage.getItem(JSON_VIEWER_MODE_KEY); - if (stored === 'tree' || stored === 'text') { - jsonDefault = stored; - } - } catch { - // Ignore localStorage errors - } - setJsonViewMode(jsonDefault); - }, [selectedFile?.path, settingsDefaultFileViewerPreview]); - - const saveTextViewMode = React.useCallback((mode: TextViewMode) => { - const selectedPath = selectedFile?.path; - if (selectedPath) { - textViewModeByPathRef.current[selectedPath] = mode; - } - setTextViewMode(mode); - }, [selectedFile?.path]); - - const saveMdViewMode = React.useCallback((mode: PreviewViewMode) => { - const selectedPath = selectedFile?.path; - if (selectedPath) { - mdViewModeByPathRef.current[selectedPath] = mode; - } - setMdViewMode(mode); - try { - localStorage.setItem(MD_VIEWER_MODE_KEY, mode); - } catch { - // Ignore localStorage errors - } - }, [selectedFile?.path]); - - const getMdViewMode = React.useCallback((): PreviewViewMode => { - return mdViewMode; - }, [mdViewMode]); - - const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => { - setJsonViewMode(mode); - try { - localStorage.setItem(JSON_VIEWER_MODE_KEY, mode); - } catch { - // Ignore localStorage errors - } - }, []); - + try { + const stored = localStorage.getItem(JSON_VIEWER_MODE_KEY); + if (stored === 'tree' || stored === 'text') { + jsonDefault = stored; + } + } catch { + // Ignore localStorage errors + } + setJsonViewMode(jsonDefault); + }, [selectedFile?.path, settingsDefaultFileViewerPreview]); + + const saveTextViewMode = React.useCallback((mode: TextViewMode) => { + const selectedPath = selectedFile?.path; + if (selectedPath) { + textViewModeByPathRef.current[selectedPath] = mode; + } + setTextViewMode(mode); + }, [selectedFile?.path]); + + const saveMdViewMode = React.useCallback((mode: PreviewViewMode) => { + const selectedPath = selectedFile?.path; + if (selectedPath) { + mdViewModeByPathRef.current[selectedPath] = mode; + } + setMdViewMode(mode); + try { + localStorage.setItem(MD_VIEWER_MODE_KEY, mode); + } catch { + // Ignore localStorage errors + } + }, [selectedFile?.path]); + + const getMdViewMode = React.useCallback((): PreviewViewMode => { + return mdViewMode; + }, [mdViewMode]); + + const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => { + setJsonViewMode(mode); + try { + localStorage.setItem(JSON_VIEWER_MODE_KEY, mode); + } catch { + // Ignore localStorage errors + } + }, []); + const saveHtmlViewMode = React.useCallback((mode: PreviewViewMode) => { - const selectedPath = selectedFile?.path; - if (selectedPath) { - htmlViewModeByPathRef.current[selectedPath] = mode; - } - setHtmlViewMode(mode); - try { - localStorage.setItem(HTML_VIEWER_MODE_KEY, mode); - } catch { - // Ignore localStorage errors - } + const selectedPath = selectedFile?.path; + if (selectedPath) { + htmlViewModeByPathRef.current[selectedPath] = mode; + } + setHtmlViewMode(mode); + try { + localStorage.setItem(HTML_VIEWER_MODE_KEY, mode); + } catch { + // Ignore localStorage errors + } }, [selectedFile?.path]); - const getHtmlViewMode = React.useCallback((): PreviewViewMode => { - return htmlViewMode; - }, [htmlViewMode]); - - React.useEffect(() => { - const applyDefaultFileViewerMode = (enabled: boolean) => { - const textMode: TextViewMode = enabled ? 'view' : 'edit'; - const previewMode: PreviewViewMode = enabled ? 'preview' : 'edit'; - const nextJsonMode: 'tree' | 'text' = enabled ? 'tree' : 'text'; - - for (const path of openPaths) { - textViewModeByPathRef.current[path] = textMode; - if (isMarkdownFile(path)) { - mdViewModeByPathRef.current[path] = previewMode; - } - if (isHtmlFile(path)) { - htmlViewModeByPathRef.current[path] = previewMode; - } - } - - setTextViewMode(textMode); - setMdViewMode(previewMode); - setHtmlViewMode(previewMode); - setJsonViewMode(nextJsonMode); - - try { - localStorage.setItem(MD_VIEWER_MODE_KEY, previewMode); - localStorage.setItem(HTML_VIEWER_MODE_KEY, previewMode); - localStorage.setItem(JSON_VIEWER_MODE_KEY, nextJsonMode); - } catch { - // Ignore localStorage errors - } - }; - - const handleFileViewerModeChanged = (event: Event) => { - const enabled = Boolean((event as CustomEvent<{ enabled?: boolean }>).detail?.enabled); - applyDefaultFileViewerMode(enabled); - }; - - window.addEventListener('openchamber:file-viewer-preview-mode-changed', handleFileViewerModeChanged); - return () => { - window.removeEventListener('openchamber:file-viewer-preview-mode-changed', handleFileViewerModeChanged); - }; - }, [openPaths]); - - React.useEffect(() => { - if (!pendingFileNavigation || !root) { - return; + const saveDrawioViewMode = React.useCallback((mode: PreviewViewMode) => { + const selectedPath = selectedFile?.path; + if (selectedPath) { + drawioViewModeByPathRef.current[selectedPath] = mode; } - - const scheduleNavigationRetry = () => { - if (typeof window === 'undefined') { - return; - } - if (pendingNavigationRafRef.current !== null) { - return; - } - - pendingNavigationRafRef.current = window.requestAnimationFrame(() => { - pendingNavigationRafRef.current = null; - setEditorViewReadyNonce((value) => value + 1); - }); - }; - - const isEditorSyncedWithDraft = (view: EditorView, expectedContent: string): boolean => { - if (view.state.doc.length !== expectedContent.length) { - return false; - } - - if (expectedContent.length === 0) { - return true; - } - - const sampleSize = Math.min(128, expectedContent.length); - const startSample = view.state.sliceDoc(0, sampleSize); - if (startSample !== expectedContent.slice(0, sampleSize)) { - return false; - } - - const endFrom = Math.max(0, expectedContent.length - sampleSize); - const endSample = view.state.sliceDoc(endFrom, expectedContent.length); - return endSample === expectedContent.slice(endFrom); - }; - - const targetPath = normalizePath(pendingFileNavigation.path); - if (!targetPath) { - setPendingFileNavigation(null); - pendingNavigationCycleRef.current = { key: '', attempts: 0 }; - return; + if (diagramAutoSaveTimerRef.current) { + clearTimeout(diagramAutoSaveTimerRef.current); + diagramAutoSaveTimerRef.current = null; } - - const navigationKey = `${targetPath}:${pendingFileNavigation.line}:${pendingFileNavigation.column ?? 1}`; - if (pendingNavigationCycleRef.current.key !== navigationKey) { - pendingNavigationCycleRef.current = { key: navigationKey, attempts: 0 }; + if (pendingDrawioPreviewFrameRef.current !== null) { + cancelAnimationFrame(pendingDrawioPreviewFrameRef.current); + pendingDrawioPreviewFrameRef.current = null; } - - if (selectedFile?.path !== targetPath) { - if (confirmDiscardOpen) { - return; - } - void handleSelectFile(toFileNode(targetPath)); - return; - } - - if (fileLoading || loadedFilePath !== targetPath) { - return; - } - - if (fileError || isSelectedImage) { - setPendingFileNavigation(null); - pendingNavigationCycleRef.current = { key: '', attempts: 0 }; - return; - } - - if (!canEdit) { - return; - } - - if (textViewMode !== 'edit') { - setTextViewMode('edit'); - return; - } - - const view = editorViewRef.current; - if (!view) { - scheduleNavigationRetry(); - return; - } - - if (!isEditorSyncedWithDraft(view, draftContent)) { - scheduleNavigationRetry(); - return; - } - - const targetLineNumber = Math.max(1, Math.min(pendingFileNavigation.line, view.state.doc.lines)); - const targetLine = view.state.doc.line(targetLineNumber); - const targetColumn = Math.max(1, pendingFileNavigation.column || 1); - const lineLength = Math.max(0, targetLine.to - targetLine.from); - const clampedColumnOffset = Math.min(lineLength, targetColumn - 1); - const targetPosition = targetLine.from + clampedColumnOffset; - const isAtTarget = view.state.selection.main.head === targetPosition; - const shouldDispatch = !isAtTarget || pendingNavigationCycleRef.current.attempts === 0; - - if (shouldDispatch) { - pendingNavigationCycleRef.current.attempts += 1; - view.dispatch({ - selection: { anchor: targetPosition }, - effects: EditorView.scrollIntoView(targetPosition, { y: 'center' }), - }); - view.focus(); - scheduleNavigationRetry(); - return; - } - - if (typeof window !== 'undefined') { - window.requestAnimationFrame(() => { - const syncedView = editorViewRef.current; - if (!syncedView) { - return; - } - - syncedView.dispatch({ - selection: { anchor: targetPosition }, - effects: EditorView.scrollIntoView(targetPosition, { y: 'center' }), - }); - syncedView.focus(); - }); - } - - setPendingFileNavigation(null); - pendingNavigationCycleRef.current = { key: '', attempts: 0 }; - }, [ - canEdit, - confirmDiscardOpen, - draftContent, - editorViewReadyNonce, - fileError, - fileLoading, - isSelectedImage, - loadedFilePath, - handleSelectFile, - pendingFileNavigation, - root, - selectedFile?.path, - setPendingFileNavigation, - textViewMode, - toFileNode, - ]); - - React.useEffect(() => { - if (!pendingFileFocusPath || !root) { - return; - } - - const targetPath = normalizePath(pendingFileFocusPath); - if (!targetPath) { - setPendingFileFocusPath(null); - return; - } - - if (selectedFile?.path !== targetPath) { - if (confirmDiscardOpen) { - return; - } - void handleSelectFile(toFileNode(targetPath)); - return; - } - - if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage) { - return; - } - - if (canEdit && textViewMode === 'edit') { - const view = editorViewRef.current; - if (!view) { - return; - } - view.focus(); - } - - setPendingFileFocusPath(null); - }, [ - canEdit, - confirmDiscardOpen, - fileError, - fileLoading, - handleSelectFile, - isSelectedImage, - loadedFilePath, - pendingFileFocusPath, - root, - selectedFile?.path, - setPendingFileFocusPath, - textViewMode, - toFileNode, - ]); - - const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => { - if (!isMobile || !view || !view.hasFocus || typeof window === 'undefined') { - return; - } - - const viewport = window.visualViewport; - if (!viewport) { - return; - } - - const layoutHeight = document.documentElement.clientHeight || window.innerHeight; - const occludedBottom = Math.max(0, layoutHeight - (viewport.offsetTop + viewport.height)); - if (occludedBottom <= 0) { - return; - } - - const head = view.state.selection.main.head; - const cursorRect = view.coordsAtPos(head); - if (!cursorRect) { - return; - } - - const visibleBottom = Math.round(viewport.offsetTop + viewport.height); - const clearance = 20; - const overlap = cursorRect.bottom + clearance - visibleBottom; - if (overlap <= 0) { - return; - } - - view.scrollDOM.scrollTop += overlap; - }, [isMobile]); - - React.useEffect(() => { - if (!isMobile || typeof window === 'undefined') { - return; - } - - const runNudge = () => { - window.requestAnimationFrame(() => { - nudgeEditorSelectionAboveKeyboard(editorViewRef.current); - }); - }; - - const viewport = window.visualViewport; - viewport?.addEventListener('resize', runNudge); - viewport?.addEventListener('scroll', runNudge, { passive: true }); - document.addEventListener('selectionchange', runNudge); - - return () => { - viewport?.removeEventListener('resize', runNudge); - viewport?.removeEventListener('scroll', runNudge); - document.removeEventListener('selectionchange', runNudge); - }; - }, [isMobile, nudgeEditorSelectionAboveKeyboard]); - - React.useEffect(() => { - if (!canEdit || textViewMode !== 'edit' || isMobile) { - return; - } - - const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides); - - const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as Element | null; - if (target?.closest('[role="dialog"]')) { - return; - } - - const isEditorTarget = Boolean(target?.closest('.cm-editor')); - const isTypingTarget = Boolean( - target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]') - ); - if (isTypingTarget && !isEditorTarget) { - return; - } - - const activeElement = document.activeElement as Element | null; - const editorHasFocus = Boolean(activeElement?.closest('.cm-editor')); - if (!editorHasFocus) { - return; - } - - if (eventMatchesShortcut(event, goToLineCombo)) { - event.preventDefault(); - setIsGoToLineOpen(true); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [canEdit, isMobile, shortcutOverrides, textViewMode]); - - const editorExtensions = React.useMemo(() => { - if (!selectedFile?.path) { - return [createFlexokiCodeMirrorTheme(currentTheme)]; - } - - const extensions = [createFlexokiCodeMirrorTheme(currentTheme)]; - const language = staticLanguageExtension ?? dynamicLanguageExtension; - if (language) { - extensions.push(language); - } - if (wrapLines) { - extensions.push(EditorView.lineWrapping); - } - if (isMobile) { - extensions.push(EditorView.updateListener.of((update) => { - if (!update.view.hasFocus) { - return; - } - if (!(update.selectionSet || update.focusChanged || update.viewportChanged || update.geometryChanged)) { - return; - } - - window.requestAnimationFrame(() => { - nudgeEditorSelectionAboveKeyboard(update.view); - }); - })); - } - return extensions; - }, [currentTheme, selectedFile?.path, staticLanguageExtension, dynamicLanguageExtension, wrapLines, isMobile, nudgeEditorSelectionAboveKeyboard]); - - const pierreTheme = React.useMemo( - () => ({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id }), - [lightTheme.metadata.id, darkTheme.metadata.id], - ); - - const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg - ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}` - : ''; - - React.useEffect(() => { - if (!imageAssetAuthKey) { - setImageAssetAuthReadyKey(''); - return; - } - - let cancelled = false; - setImageAssetAuthReadyKey(''); - void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()) - .then((token) => { - if (!cancelled && token) setImageAssetAuthReadyKey(imageAssetAuthKey); - }) - .catch(() => {}); - - return () => { - cancelled = true; - }; - }, [imageAssetAuthKey]); - - const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey); - - const imageSrc = selectedFile?.path && isSelectedImage - ? (runtime.isDesktop - ? (isSelectedSvg - ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` - : desktopImageSrc) - : (isSelectedSvg - ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` - : imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { - path: selectedFile.path, - allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, - }) : '')) - : ''; - - React.useEffect(() => { - let cancelled = false; - - const resolveDesktopImage = async () => { - if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) { - setDesktopImageSrc(''); - return; - } - - setFileError(null); - - const srcPromise = files.readFileBinary - ? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl) - : Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { - path: selectedFile.path, - allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, - })); - - await srcPromise - .then((src) => { - if (!cancelled) { - setDesktopImageSrc(src); - setLoadedFilePath(selectedFile.path); - } - }) - .catch((error) => { - if (!cancelled) { - setDesktopImageSrc(''); - setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); - setLoadedFilePath(null); - } - }) - .finally(() => { - if (!cancelled) { - setFileLoading(false); + if (mode === 'edit') { + setDraftContent(diagramXmlRef.current || fileContent); + setDrawioViewMode(mode); + } else { + diagramXmlRef.current = draftContent; + const pathAtToggle = selectedPath; + setDrawioViewMode('edit'); + pendingDrawioPreviewFrameRef.current = requestAnimationFrame(() => { + pendingDrawioPreviewFrameRef.current = requestAnimationFrame(() => { + pendingDrawioPreviewFrameRef.current = null; + if (root && pathAtToggle && useFilesViewTabsStore.getState().byRoot[root]?.selectedPath !== pathAtToggle) { + return; } + setDrawioRemountNonce((value) => value + 1); + setDrawioViewMode('preview'); }); - }; + }); + return; + } + }, [draftContent, fileContent, root, selectedFile?.path]); - void resolveDesktopImage(); - - return () => { - cancelled = true; - }; - }, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]); - - const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []); - - const blockWidgets = React.useMemo(() => { - return buildCodeMirrorCommentWidgets({ - drafts: filesFileDrafts, - editingDraftId, - commentText, - selection: lineSelection, - isDragging, - fileLabel: selectedFile?.path ?? '', - newWidgetId: 'files-new-comment-input', - mapDraftToRange: (draft) => ({ start: draft.startLine, end: draft.endLine }), - onSave: handleSaveComment, - onCancel: () => { - setLineSelection(null); - cancel(); - }, - onEdit: (draft) => { - startEdit(draft); - setLineSelection({ start: draft.startLine, end: draft.endLine }); - }, - onDelete: deleteDraft, - }); - }, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, startEdit]); - - const renderShikiFileView = React.useCallback((file: FileNode, content: string) => { - return ( -
    - -
    - ); - }, [currentTheme.metadata.variant, pierreTheme, wrapLines]); - - const renderFloatingFileControls = ({ exitFullscreenOnly = false }: { exitFullscreenOnly?: boolean } = {}) => { - if (!selectedFile) { - return null; + const saveDiagramXml = React.useCallback(async (path: string, xml: string) => { + if (!files.writeFile || xml === diagramSavedXmlRef.current) { + return false; } - const withTooltip = (label: React.ReactNode, trigger: React.ReactElement) => ( - - - - {trigger} - - - {label} - - ); + const result = await files.writeFile(path, xml); + if (!result?.success) { + toast.error(t('filesView.toast.writeFileFailed')); + return false; + } - return ( -
    - {canEdit && textViewMode === 'edit' && ( - <> - {isSaving ? ( - - - {t('filesView.editor.saving')} - - ) : autoSaveEnabled && autoSaveStatus === 'saved' && !isDirty ? ( - - - {t('filesView.editor.saved')} - - ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }), - - ) : null} - {withTooltip(autoSaveEnabled ? t('filesView.editor.autoSaveOn') : t('filesView.editor.manualSave'), - - )} - - )} + diagramXmlRef.current = xml; + diagramSavedXmlRef.current = xml; + setDraftContent(xml); + const stat = await readFileStat(path, selectedFileReadOptions).catch(() => null); + if (stat) { + lastLoadedFileStatRef.current = stat; + } + return true; + }, [files, readFileStat, selectedFileReadOptions, t]); - - - - - - - - - - {t('filesView.editor.openInDesktopApp')} - - - {openInApps.map((app) => ( - void handleOpenInApp(app)} - > - - {app.label} - - ))} - {openInCacheStale ? ( - void loadOpenInApps(true)} - > - - {t('filesView.editor.refreshApps')} - - ) : null} - - + React.useEffect(() => { + return () => { + if (diagramAutoSaveTimerRef.current) { + clearTimeout(diagramAutoSaveTimerRef.current); + diagramAutoSaveTimerRef.current = null; + } + if (pendingDrawioPreviewFrameRef.current !== null) { + cancelAnimationFrame(pendingDrawioPreviewFrameRef.current); + pendingDrawioPreviewFrameRef.current = null; + } + }; + }, [drawioViewMode, selectedFile?.path]); - {!isSelectedImage && ( - <> - {withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'), - - )} - {textViewMode === 'edit' && ( - <> - {withTooltip(t('filesView.editor.findInFile'), - - )} - {withTooltip(t('filesView.editor.goToLine'), - - )} - - - )} - - )} + const handleDiagramChange = React.useCallback((xml: string) => { + diagramXmlRef.current = xml; + if (!selectedFile?.path || drawioViewMode !== 'preview' || !files.writeFile) { + return; + } - {canUseShikiFileView && canEdit && !isJson && !isHtml && ( - { - saveTextViewMode(textViewMode === 'view' ? 'edit' : 'view'); - }} - /> - )} + if (diagramAutoSaveTimerRef.current) { + clearTimeout(diagramAutoSaveTimerRef.current); + } - {(isMarkdown || isHtmlFile(selectedFile?.path ?? '')) && ( - { - if (isHtmlFile(selectedFile?.path ?? '')) { - saveHtmlViewMode(getHtmlViewMode() === 'preview' ? 'edit' : 'preview'); - } else { - saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview'); - } - }} - /> - )} + const path = selectedFile.path; + diagramAutoSaveTimerRef.current = setTimeout(() => { + diagramAutoSaveTimerRef.current = null; + void saveDiagramXml(path, xml).then((saved) => { + if (!saved) return; + setDiagramSaved(true); + setTimeout(() => setDiagramSaved(false), 1500); + }).catch((error) => { + toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed')); + }); + }, AUTO_SAVE_DELAY); + }, [drawioViewMode, files.writeFile, saveDiagramXml, selectedFile?.path, t]); - {isJson && ( - withTooltip(jsonViewMode === 'tree' ? t('filesView.editor.switchToTextView') : t('filesView.editor.switchToTreeView'), - - ) - )} - - {canCopy && ( - withTooltip(t('filesView.editor.copyFileContents'), - - ) - )} - - {canCopyPath && ( - withTooltip(t('filesView.editor.copyFilePathTitle', { path: displaySelectedPath }), - - ) - )} - - {files.downloadFile && ( - withTooltip(t('filesView.editor.saveFile'), - - ) - )} - - {exitFullscreenOnly ? ( - withTooltip(t('filesView.editor.exitFullscreen'), - - ) - ) : (!isMobile && mode === 'full' && ( - withTooltip(isFullscreen ? t('filesView.editor.exitFullscreen') : t('filesView.editor.fullscreen'), - - ) - ))} -
    - ); - }; - - const fileViewer = ( -
    - { - // Intentionally no "cancel" action. Keep dialog modal. - if (!open) { - setConfirmDiscardOpen(true); - } - }}> - - - {t('filesView.unsaved.title')} - - {t('filesView.unsaved.description')} - - - - - - - - -
    - {/* Row 1: Tabs */} - {showEditorTabsRow ? ( -
    - {isMobile && showMobilePageContent && ( - - )} - - {isMobile ? ( - selectedFile ? ( - - - - - - {openFiles.map((file) => { - const isActive = selectedFile?.path === file.path; - return ( - { - const target = event.target as HTMLElement; - if (target.closest('[data-close-open-file]')) { - event.preventDefault(); - return; - } - if (!isActive) { - void handleSelectFile(file); - } - }} - className={cn( - 'flex min-w-0 items-center justify-between gap-2 overflow-hidden', - isActive && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]' - )} - > - - - - - - - ); - })} - - - ) : ( -
    {t('filesView.editor.selectFile')}
    - ) - ) : ( - openFiles.length > 0 ? ( -
    - {editorTabsOverflow.left && ( -
    - )} - {editorTabsOverflow.right && ( -
    - )} -
    - {openFiles.map((file) => { - const isActive = selectedFile?.path === file.path; - return ( -
    - - - -
    - ); - })} -
    -
    - ) : ( -
    {t('filesView.editor.selectFile')}
    - ) - )} -
    - ) : null} - -
    - -
    - {selectedFile && !isSearchOpen && ( -
    setIsFloatingToolbarOpen(true)} - onMouseLeave={() => { - if (toolbarDropdownOpenCountRef.current > 0) return; - setIsFloatingToolbarOpen(false); - }} - > - {isFloatingToolbarOpen ? ( - renderFloatingFileControls() - ) : ( - - - - - - - {t('filesView.editor.controlsTitle')} - - )} -
    - )} - - {!selectedFile ? ( -
    {t('filesView.editor.pickFileFromTree')}
    - ) : (fileLoading || isImageAssetAuthLoading) ? ( - suppressFileLoadingIndicator - ?
    - : ( -
    - - {t('filesView.state.loading')} -
    - ) - ) : fileError ? ( -
    {fileError}
    - ) : isSelectedImage ? ( -
    - {selectedFile?.name -
    - ) : selectedFile && isJson && jsonViewMode === 'tree' ? ( - -
    {t('filesView.error.jsonViewerUnavailable')}
    -
    - {t('filesView.error.switchToTextMode')} -
    -
    - } - > -
    - -
    - - ) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? ( -
    - {fileContent.length > 500 * 1024 && ( -
    - {t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })} -
    - )} - -
    {t('filesView.error.previewUnavailable')}
    -
    - {t('filesView.error.switchToEditMode')} -
    -
    - } - > - - -
    - ) : selectedFile && isHtml && htmlViewMode === 'preview' ? ( -
    -