From d9b9b56599165bdc0e7f96e5d81c276010d543d6 Mon Sep 17 00:00:00 2001 From: nerdosaurus <58043629+nerdosaurus@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:50:56 -0400 Subject: [PATCH] Diagram editor pr (#1432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add draw.io diagram editor integration Embed draw.io editor via react-drawio (MIT, zero deps) for inline editing of .drawio files. Changes auto-save to disk. Includes inline editor in FilesView with Visual/Source toggle, dark mode support, template picker for new files, and chat file attachment integration. * fix: debounce diagram autosave to prevent reload loop * fix: ignore watcher-triggered xml prop changes to prevent reload loop * fix: remove auto-save-to-disk, add manual save button for diagrams Autosave writes triggered file watcher cascade that reloaded the draw.io iframe and reset zoom. Replaced with explicit Save button in the toolbar (floppy disk icon). Editor XML is stable on mount and ignores watcher-triggered prop changes. * fix: remove auto-save write from DiagramView, add save button * fix: hide draw.io save/exit buttons in editor * fix: also hide save-and-exit button * fix: brighten save button styling, add saved confirmation * fix: remove autoSaveStatus toggle on diagram save to prevent toolbar collapse * fix: add local save confirmation state for diagram button * fix: remount drawio iframe on theme change, persisting XML across mounts * fix: clear persisted xml on mount to prevent leaking between files * fix: initialize dark mode synchronously, preserve edits across theme remount * fix: auto-focus drawio iframe on mount/theme-change for keyboard shortcuts * fix: add diagram i18n keys to Traditional Chinese locale * fix: restore upstream HMR host and LAN address support * fix: load sub-agent sessions on bootstrap for sidebar visibility Two-phase session load: first fetch root sessions (for accurate sessionTotal), then fetch all sessions and include child sessions (sub-agent delegations). This ensures sub-agent sessions appear in the sidebar immediately instead of relying on the async global session store. * remove opencode-drawio from PR branch * fix: atomic file writes to prevent concurrent read/write truncation Three-layer defense against the O_TRUNC race: 1. Write side (server): replace direct writeFile with write-to-temp- then-rename. fs.rename is atomic on POSIX. 2. Read side (server): retry up to 3 times with 50ms backoff when readFile returns empty but stat reported non-zero size. 3. FilesView client: refuse to save empty draftContent when the original fileContent was non-empty. * fix(dev): clean up orphaned OpenCode processes on Ctrl+C * fix: allow empty file saves, log warning instead of blocking Replaces the hard block on saving empty content with a console.warn. The atomic write + read retry on the server side handle the O_TRUNC race properly. The previous guard caused a UX regression by silently preventing users from clearing a file and saving. * fix: remove time window from sub-agent fallback for live tasks While a task tool is active, the fallback now matches any session with the correct parentID regardless of creation time. This allows late-appearing child sessions to be found when the OpenCode server is slow or the SSE event pipeline is delayed. The time window is still applied once the task tool has completed, as a final sanity check. * fix: three diagram editor bugs from Greptile review 1. stableXmlRef now resets when xml prop changes — switching between .drawio files renders the correct content. 2. Focus effect only runs on mount, not on isDark changes — theme toggle no longer steals keyboard focus 600ms later. 3. saveDiagram updates xml state after writing — dirty-check guard works correctly for subsequent saves. * fix: route session.created SSE events to correct directory Three-layer fix for sub-agent sessions not appearing in sidebar and inline chat: 1. protocol.js: parseSseEventEnvelope now extracts directory from properties.info.directory (where session.created/updated events carry it) in addition to properties.directory. WS frames relayed to the browser now carry the real directory instead of 'global', so child sessions routed to the correct directory store. 2. event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth when SSE events bypass the WS relay. 3. resolveFallbackTaskSessionId.ts: time window lower bound now allows 2s grace before taskStartTime to accommodate server timing jitter (child session creation timestamps consistently precede the tool's recorded start by ~6-9ms), fixing the 'Open subtask' button not rendering in OpenChamber's inline chat. * fix: sub-agent sidebar visibility, file zeroing guard, inline badge fallback - Sync watchdog: periodic child session discovery poll (every 15s) detects sessions created by other OpenCode instances, triggers parent materialization - protocol.js: parseSseEventEnvelope extracts directory from properties.info.directory for session.created/updated events - event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth - resolveFallbackTaskSessionId: don't require taskStartTime (cross-OpenCode); pick most recent child when multiple idle candidates exist - readTaskSessionIdFromOutput: parse format from output - FilesView: reinstate empty-draft guard (block save when draftContent='' but fileContent had content) to prevent file zeroing on tab switch * Fix diagram autosave reload loop * Highlight drawio files as XML * Use diff-compatible highlighting for drawio files * Restore drawio file icon mapping * Stabilize drawio source preview toggle --------- Co-authored-by: Bohdan Triapitsyn --- bun.lock | 16 +- package.json | 1 + packages/ui/package.json | 1 + .../ui/src/components/chat/FileAttachment.tsx | 38 + .../chat/message/parts/ToolPart.test.ts | 13 + .../chat/message/parts/ToolPart.tsx | 12 +- .../parts/resolveFallbackTaskSessionId.ts | 225 +- .../chat/message/parts/taskSessionIdParser.ts | 4 + .../src/components/diagram/DiagramEditor.tsx | 109 + packages/ui/src/components/diagram/index.ts | 1 + packages/ui/src/components/layout/Header.tsx | 1 + .../ui/src/components/layout/MainLayout.tsx | 3 + .../ui/src/components/views/DiagramView.tsx | 102 + .../ui/src/components/views/FilesView.tsx | 7595 +++++++++-------- .../src/lib/codemirror/languageByExtension.ts | 3 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + packages/ui/src/lib/router/types.ts | 2 +- packages/ui/src/lib/toolHelpers.ts | 7 + packages/ui/src/stores/useUIStore.ts | 27 +- packages/ui/src/sync/event-pipeline.ts | 15 +- packages/ui/src/sync/sync-context.tsx | 5273 ++++++------ .../web/server/lib/event-stream/protocol.js | 4 +- packages/web/server/lib/fs/routes.js | 27 +- scripts/dev-web-hmr.mjs | 68 +- 31 files changed, 7102 insertions(+), 6477 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/ToolPart.test.ts create mode 100644 packages/ui/src/components/chat/message/parts/taskSessionIdParser.ts create mode 100644 packages/ui/src/components/diagram/DiagramEditor.tsx create mode 100644 packages/ui/src/components/diagram/index.ts create mode 100644 packages/ui/src/components/views/DiagramView.tsx 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' ? ( -
    -