diff --git a/bun.lock b/bun.lock index 34a6488ba01..2a1303fcbb8 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", + "monaco-editor": "^0.52.0", "remeda": "catalog:", "shiki": "catalog:", "solid-js": "catalog:", @@ -189,6 +190,7 @@ "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", + "@tauri-apps/plugin-fs": "~2", "@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-notification": "~2", "@tauri-apps/plugin-opener": "^2", @@ -1746,6 +1748,8 @@ "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="], + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.5", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA=="], + "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg=="], "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], @@ -3102,6 +3106,8 @@ "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "monaco-editor": ["monaco-editor@0.52.2", "", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/packages/app/package.json b/packages/app/package.json index b4a15cec084..68cc9fe0073 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -57,6 +57,7 @@ "ghostty-web": "0.3.0", "luxon": "catalog:", "marked": "catalog:", + "monaco-editor": "^0.52.0", "marked-shiki": "catalog:", "remeda": "catalog:", "shiki": "catalog:", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d5009c8d1d1..51def31fdc5 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -23,6 +23,7 @@ import { CommentsProvider } from "@/context/comments" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" +import { EditorProvider } from "@/context/editor" import { LanguageProvider, useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { Logo } from "@opencode-ai/ui/logo" @@ -106,7 +107,9 @@ export function AppInterface(props: { defaultUrl?: string }) { - {props.children} + + {props.children} + diff --git a/packages/app/src/components/editor-panel.tsx b/packages/app/src/components/editor-panel.tsx new file mode 100644 index 00000000000..a8410b77493 --- /dev/null +++ b/packages/app/src/components/editor-panel.tsx @@ -0,0 +1,571 @@ +import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import * as monaco from "monaco-editor" +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker" +import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker" +import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker" +import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker" +import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker" +import { usePlatform } from "@/context/platform" +import { useTheme } from "@opencode-ai/ui/theme" +import { useEditor } from "@/context/editor" +import { useSettings } from "@/context/settings" +import { getFilename } from "@opencode-ai/util/path" +import { Icon } from "@opencode-ai/ui/icon" + +// Set up Monaco workers +self.MonacoEnvironment = { + getWorker(_, label) { + if (label === "json") return new jsonWorker() + if (label === "css" || label === "scss" || label === "less") return new cssWorker() + if (label === "html" || label === "handlebars" || label === "razor") return new htmlWorker() + if (label === "typescript" || label === "javascript") return new tsWorker() + return new editorWorker() + }, +} + +// Helper to get computed CSS variable value as a resolved color +function getCssVar(name: string): string { + // Try reading from document.body first, then documentElement + let value = getComputedStyle(document.body).getPropertyValue(name).trim() + if (!value) { + value = getComputedStyle(document.documentElement).getPropertyValue(name).trim() + } + return value +} + +// Convert any color to hex format (Monaco requires hex colors) +function toHexColor(color: string): string { + if (!color) return "" + // If already hex, return as-is + if (color.startsWith("#")) return color + // Use a temporary element to convert any CSS color to rgb + const temp = document.createElement("div") + temp.style.color = color + document.body.appendChild(temp) + const computed = getComputedStyle(temp).color + document.body.removeChild(temp) + // Parse rgb(r, g, b) or rgba(r, g, b, a) + const match = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/) + if (match) { + const r = parseInt(match[1]).toString(16).padStart(2, "0") + const g = parseInt(match[2]).toString(16).padStart(2, "0") + const b = parseInt(match[3]).toString(16).padStart(2, "0") + return `#${r}${g}${b}` + } + return color +} + +// Define custom OpenCode theme for Monaco that uses CSS variables +function defineOpenCodeTheme() { + const isDark = document.documentElement.dataset.colorScheme === "dark" + + // Get colors from CSS variables and convert to hex + const bgColorRaw = getCssVar("--surface-raised-stronger-non-alpha") + const bgColor = toHexColor(bgColorRaw) || (isDark ? "#1e1e1e" : "#ffffff") + const fgColor = isDark ? "#d4d4d4" : "#000000" + const lineNumberColor = isDark ? "#858585" : "#237893" + const selectionBg = isDark ? "#264f78" : "#add6ff" + + monaco.editor.defineTheme("opencode", { + base: isDark ? "vs-dark" : "vs", + inherit: true, + rules: [], + colors: { + "editor.background": bgColor, + "editor.foreground": fgColor, + "editorLineNumber.foreground": lineNumberColor, + "editor.selectionBackground": selectionBg, + "editor.lineHighlightBackground": isDark ? "#ffffff0a" : "#00000007", + "editorCursor.foreground": fgColor, + }, + }) +} + +function getLanguageFromPath(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() || "" + const languageMap: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + json: "json", + html: "html", + htm: "html", + css: "css", + scss: "scss", + less: "less", + md: "markdown", + py: "python", + rs: "rust", + go: "go", + java: "java", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + cs: "csharp", + rb: "ruby", + php: "php", + swift: "swift", + kt: "kotlin", + scala: "scala", + sh: "shell", + bash: "shell", + zsh: "shell", + yaml: "yaml", + yml: "yaml", + toml: "ini", + xml: "xml", + sql: "sql", + graphql: "graphql", + } + return languageMap[ext] || "plaintext" +} + +// Cache for file content and view states +type FileCache = { + originalContent: string // Content as loaded from disk + currentContent: string // Current content in editor (may be modified) + viewState: monaco.editor.ICodeEditorViewState | null + language: string +} + +export function EditorPanel(props: { class?: string }) { + const platform = usePlatform() + const theme = useTheme() + const editorCtx = useEditor() + const settings = useSettings() + + let containerRef: HTMLDivElement | undefined + const [editor, setEditor] = createSignal(undefined) + + // Cache for each open file + const fileCache = new Map() + + // File watchers for each open file + const fileWatchers = new Map void>() + + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(null) + const [externalChange, setExternalChange] = createSignal(false) + + // Flag to ignore content changes when programmatically setting value + let isSettingContent = false + + // Track the previous tab to save state before switching + let previousTabPath: string | undefined + + // Initialize Monaco theme based on app color scheme + const initMonacoTheme = () => { + defineOpenCodeTheme() + return "opencode" + } + + const loadFileContent = async (filePath: string, isReload: boolean = false) => { + const ed = editor() + if (!platform.readFile || !ed) return + + if (!isReload) { + setLoading(true) + setError(null) + } + + try { + const fileContent = await platform.readFile(filePath) + const cached = fileCache.get(filePath) + + // If this is a reload and file is dirty, check if disk content changed + if (isReload && cached && fileContent !== cached.originalContent) { + if (editorCtx.getTab(filePath)?.isDirty) { + setExternalChange(true) + return + } + } + + const language = getLanguageFromPath(filePath) + + // Update cache + fileCache.set(filePath, { + originalContent: fileContent, + currentContent: fileContent, + viewState: cached?.viewState ?? null, + language, + }) + + // If this is the active file, update the editor + if (editorCtx.activeTab() === filePath) { + isSettingContent = true + ed.setValue(fileContent) + isSettingContent = false + const model = ed.getModel() + if (model) { + monaco.editor.setModelLanguage(model, language) + } + if (cached?.viewState) { + ed.restoreViewState(cached.viewState) + } + } + + editorCtx.setTabDirty(filePath, false) + setExternalChange(false) + } catch (err) { + if (!isReload) { + setError(err instanceof Error ? err.message : "Failed to load file") + if (editorCtx.activeTab() === filePath) { + isSettingContent = true + ed.setValue("") + isSettingContent = false + } + } + } finally { + if (!isReload) { + setLoading(false) + } + } + } + + // Save current view state before switching tabs + const saveCurrentViewState = (path: string | undefined) => { + const ed = editor() + if (ed && path) { + const cached = fileCache.get(path) + if (cached) { + cached.viewState = ed.saveViewState() + cached.currentContent = ed.getValue() + } + } + } + + // Start watching a file for changes + const startWatching = async (filePath: string) => { + // Stop any existing watcher for this file + stopWatching(filePath) + + if (!platform.watchFile) return + + try { + const unwatch = await platform.watchFile(filePath, async (event) => { + // Only react to modify events + if (event.type !== "modify") return + + // Check if this file is still open + const tab = editorCtx.getTab(filePath) + if (!tab) return + + const isActiveTab = editorCtx.activeTab() === filePath + + // Active tab - always prompt the user, never auto-reload + if (isActiveTab) { + editorCtx.setTabExternalChanges(filePath, true) + setExternalChange(true) + return + } + + // Background tab with auto-reload enabled - reload silently + if (settings.editor.autoReloadBackgroundFiles()) { + editorCtx.setTabDirty(filePath, false) + editorCtx.setTabExternalChanges(filePath, false) + await loadFileContent(filePath, true) + return + } + + // Background tab with auto-reload disabled - mark for prompt when tab becomes active + editorCtx.setTabExternalChanges(filePath, true) + }) + + fileWatchers.set(filePath, unwatch) + } catch (err) { + console.error("Failed to watch file:", filePath, err) + } + } + + const stopWatching = (filePath: string) => { + const unwatch = fileWatchers.get(filePath) + if (unwatch) { + unwatch() + fileWatchers.delete(filePath) + } + } + + const stopAllWatchers = () => { + for (const unwatch of fileWatchers.values()) { + unwatch() + } + fileWatchers.clear() + } + + const reloadFromDisk = async () => { + const path = editorCtx.activeTab() + if (path) { + editorCtx.setTabDirty(path, false) + editorCtx.setTabExternalChanges(path, false) + setExternalChange(false) + await loadFileContent(path, false) + } + } + + const keepEditorVersion = () => { + const path = editorCtx.activeTab() + if (path) { + // Keep the editor content, mark external changes as resolved + // The file is still dirty (has unsaved changes) + editorCtx.setTabExternalChanges(path, false) + setExternalChange(false) + + // Update the original content in cache to match what's in the editor + // This way, saving will overwrite the disk version + const cached = fileCache.get(path) + const ed = editor() + if (cached && ed) { + // Note: we don't update originalContent here because we want + // to preserve the dirty state. The user chose to keep their edits. + } + } + } + + const saveFile = async () => { + const ed = editor() + if (!platform.writeFile || !ed) return + + const path = editorCtx.activeTab() + if (!path) return + + try { + const content = ed.getValue() + await platform.writeFile(path, content) + + // Update cache - saved content becomes the new original + const cached = fileCache.get(path) + if (cached) { + cached.originalContent = content + cached.currentContent = content + } + + editorCtx.setTabDirty(path, false) + editorCtx.setTabExternalChanges(path, false) + setExternalChange(false) + } catch (err) { + console.error("Failed to save file:", err) + } + } + + const handleCloseTab = (e: MouseEvent, path: string) => { + e.stopPropagation() + // Stop watching and remove from cache + stopWatching(path) + fileCache.delete(path) + editorCtx.closeTab(path) + } + + onMount(() => { + if (!containerRef) return + + const ed = monaco.editor.create(containerRef, { + value: "", + language: "plaintext", + theme: initMonacoTheme(), + automaticLayout: true, + minimap: { enabled: false }, + fontSize: 13, + lineNumbers: "on", + scrollBeyondLastLine: false, + wordWrap: "off", + tabSize: 2, + renderWhitespace: "selection", + folding: true, + padding: { top: 8, bottom: 8 }, + }) + + // Track changes - only mark dirty if user made the change + ed.onDidChangeModelContent(() => { + if (isSettingContent) return + const path = editorCtx.activeTab() + if (path) { + const cached = fileCache.get(path) + if (cached) { + const currentValue = ed.getValue() + cached.currentContent = currentValue + // Compare with original to determine dirty state + const isDirty = currentValue !== cached.originalContent + editorCtx.setTabDirty(path, isDirty) + } + } + }) + + // Save on Ctrl+S / Cmd+S + ed.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveFile) + + // Set the editor signal - this triggers reactive effects + setEditor(ed) + }) + + // Function to update Monaco theme from current CSS variables + const updateMonacoTheme = () => { + const ed = editor() + if (ed) { + defineOpenCodeTheme() + monaco.editor.setTheme("opencode") + } + } + + // Update theme when app theme changes (for committed theme changes) + createEffect(() => { + // Track all theme-related signals to trigger updates + theme.mode() + theme.themeId() + theme.colorScheme() + const ed = editor() + if (ed) { + // Use setTimeout to ensure CSS variables have been updated after theme change + setTimeout(updateMonacoTheme, 50) + } + }) + + // Watch for theme preview changes via MutationObserver on the theme style element + onMount(() => { + const themeStyleEl = document.getElementById("oc-theme") + if (themeStyleEl) { + const observer = new MutationObserver(() => { + // Small delay to ensure CSS is applied + setTimeout(updateMonacoTheme, 10) + }) + observer.observe(themeStyleEl, { childList: true, characterData: true, subtree: true }) + onCleanup(() => observer.disconnect()) + } + }) + + onCleanup(() => { + stopAllWatchers() + editor()?.dispose() + }) + + // Load file content when active tab changes + createEffect(() => { + const filePath = editorCtx.activeTab() + const ed = editor() + + if (!filePath || !platform.readFile || !ed) { + if (ed) { + isSettingContent = true + ed.setValue("") + isSettingContent = false + } + previousTabPath = undefined + return + } + + // Save view state of previous file (only if different from current) + if (previousTabPath && previousTabPath !== filePath) { + saveCurrentViewState(previousTabPath) + } + previousTabPath = filePath + + // Check if we have cached content - this is synchronous and fast + const cached = fileCache.get(filePath) + if (cached) { + isSettingContent = true + ed.setValue(cached.currentContent) + isSettingContent = false + const model = ed.getModel() + if (model) { + monaco.editor.setModelLanguage(model, cached.language) + } + if (cached.viewState) { + ed.restoreViewState(cached.viewState) + } + setError(null) + setLoading(false) + + // Show external change prompt if this tab has pending external changes + const tab = editorCtx.getTab(filePath) + if (tab?.hasExternalChanges) { + setExternalChange(true) + } else { + setExternalChange(false) + } + } else { + // Load from disk asynchronously and start watching + loadFileContent(filePath).then(() => { + startWatching(filePath) + }) + } + + // Start watching if we loaded from cache + if (fileCache.has(filePath) && !fileWatchers.has(filePath)) { + startWatching(filePath) + } + }) + + return ( +
+ {/* Tab bar */} + 0}> +
+ + {(tab) => { + const isActive = () => editorCtx.activeTab() === tab.path + const tabPath = tab.path + return ( +
editorCtx.setActiveTab(tabPath)} + title={tabPath} + > + {getFilename(tabPath)} + + + + + + +
handleCloseTab(e, tabPath)} + title="Close" + > + +
+
+ ) + }} +
+
+
+ + {/* Status bar for active file */} + +
+ + {editorCtx.activeTab()} + + + Loading... + + + {error()} + + + File changed on disk + + +
+
+ + {/* Editor container */} +
+ + {/* Empty state */} + +
+ Select a file to edit +
+
+
+ ) +} diff --git a/packages/app/src/components/explorer-panel.tsx b/packages/app/src/components/explorer-panel.tsx new file mode 100644 index 00000000000..1126c97883e --- /dev/null +++ b/packages/app/src/components/explorer-panel.tsx @@ -0,0 +1,870 @@ +import { createEffect, createMemo, createSignal, createResource, For, Show, onCleanup } from "solid-js" +import { Portal } from "solid-js/web" +import { useParams } from "@solidjs/router" +import { base64Decode } from "@opencode-ai/util/encode" +import { Icon } from "@opencode-ai/ui/icon" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" + +export type FileExplorerItem = { + name: string + path: string + isDirectory: boolean + expanded: boolean + children: FileExplorerItem[] +} + +type ClipboardState = { + path: string + operation: "copy" | "cut" +} | null + +export function ExplorerPanel(props: { onFileOpen: (path: string) => void; class?: string; projectDir?: string }) { + const params = useParams() + const platform = usePlatform() + const language = useLanguage() + const [tree, setTree] = createSignal([]) + const [clipboard, setClipboard] = createSignal(null) + const [renamingPath, setRenamingPath] = createSignal(null) + const [renameValue, setRenameValue] = createSignal("") + const [contextMenuPosition, setContextMenuPosition] = createSignal<{ x: number; y: number } | null>(null) + const [contextMenuItem, setContextMenuItem] = createSignal(null) + const [showNewFileInput, setShowNewFileInput] = createSignal<{ parentPath: string; afterItemPath?: string; type: "file" | "directory" } | null>(null) + const [newItemName, setNewItemName] = createSignal("") + const [deleteConfirm, setDeleteConfirm] = createSignal(null) + let scrollContainerRef: HTMLDivElement | undefined + let newFileInputRef: HTMLInputElement | undefined + let renameInputRef: HTMLInputElement | undefined + + // Helper to update tree while preserving scroll position + function updateTreeWithScroll(updater: (prev: FileExplorerItem[]) => FileExplorerItem[]) { + const scrollTop = scrollContainerRef?.scrollTop ?? 0 + setTree(updater) + requestAnimationFrame(() => { + if (scrollContainerRef) { + scrollContainerRef.scrollTop = scrollTop + } + }) + } + + + + const projectRoot = createMemo(() => { + const dir = props.projectDir || params.dir + if (!dir) return null + return props.projectDir || base64Decode(dir) + }) + + const [treeResource, { refetch }] = createResource( + () => { + const dir = projectRoot() + return dir && platform.readDirectory ? { dir, platform: platform } : null + }, + async ({ dir, platform }) => { + if (!platform.readDirectory) { + return [] + } + try { + const entries = await platform.readDirectory(dir) + + const items: FileExplorerItem[] = entries + .sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1 + if (!a.isDirectory && b.isDirectory) return 1 + return a.name.localeCompare(b.name) + }) + .map((entry) => ({ + ...entry, + expanded: false, + children: [], + })) + + return items + } catch (err) { + console.error("Failed to load directory:", err) + return [] + } + }, + ) + + createEffect(() => { + const data = treeResource() + if (data) { + // Preserve scroll when tree data updates from resource + const scrollTop = scrollContainerRef?.scrollTop ?? 0 + setTree(data) + requestAnimationFrame(() => { + if (scrollContainerRef) { + scrollContainerRef.scrollTop = scrollTop + } + }) + } + }) + + // Helper to get all expanded paths from tree + function getExpandedPaths(nodes: FileExplorerItem[]): Set { + const paths = new Set() + const traverse = (items: FileExplorerItem[]) => { + for (const item of items) { + if (item.expanded) { + paths.add(item.path) + traverse(item.children) + } + } + } + traverse(nodes) + return paths + } + + // Recursively build tree with expanded folders already loaded + async function buildTreeWithExpandedState(dir: string, expandedPaths: Set): Promise { + if (!platform.readDirectory) return [] + + try { + const entries = await platform.readDirectory(dir) + const items: FileExplorerItem[] = entries + .sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1 + if (!a.isDirectory && b.isDirectory) return 1 + return a.name.localeCompare(b.name) + }) + .map((entry) => ({ + ...entry, + expanded: false, + children: [], + })) + + // Load children for expanded folders + for (const item of items) { + if (item.isDirectory && expandedPaths.has(item.path)) { + item.expanded = true + item.children = await buildTreeWithExpandedState(item.path, expandedPaths) + } + } + + return items + } catch (err) { + console.error("Failed to load directory:", err) + return [] + } + } + + // Refresh tree while preserving expanded folder state (no flash) + async function refreshTreePreservingState() { + const root = projectRoot() + if (!root) return + + const expandedPaths = getExpandedPaths(tree()) + const newTree = await buildTreeWithExpandedState(root, expandedPaths) + + // Single update - no flash + updateTreeWithScroll(() => newTree) + } + + // Watch the project folder for changes (debounced to prevent spam) + createEffect(() => { + const root = projectRoot() + if (!root || !platform.watchFile) return + + let unwatch: (() => void) | null = null + let debounceTimer: ReturnType | null = null + + platform.watchFile(root, async (event) => { + // Debounce rapid file changes + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + refreshTreePreservingState() + }, 300) + }).then((unwatchFn) => { + unwatch = unwatchFn + }).catch((err) => { + console.error("Failed to watch project folder:", err) + }) + + onCleanup(() => { + if (debounceTimer) clearTimeout(debounceTimer) + if (unwatch) { + unwatch() + } + }) + }) + + async function toggleExpand(item: FileExplorerItem) { + if (!item.isDirectory) return + + if (item.expanded) { + // Collapse: just update the expanded state + updateTreeWithScroll((prev) => { + const updateNode = (nodes: FileExplorerItem[]): FileExplorerItem[] => { + return nodes.map((node) => { + if (node.path === item.path) { + return { ...node, expanded: false, children: [] } + } + if (node.children.length > 0) { + return { ...node, children: updateNode(node.children) } + } + return node + }) + } + return updateNode(prev) + }) + } else { + // Expand: load children + await loadChildren(item.path) + } + } + + async function loadChildren(path: string) { + if (platform.readDirectory) { + try { + const entries = await platform.readDirectory(path) + const children: FileExplorerItem[] = entries + .sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1 + if (!a.isDirectory && b.isDirectory) return 1 + return a.name.localeCompare(b.name) + }) + .map((entry) => ({ + ...entry, + expanded: false, + children: [], + })) + + updateTreeWithScroll((prev) => { + const updateNode = (nodes: FileExplorerItem[]): FileExplorerItem[] => { + return nodes.map((node) => { + if (node.path === path) { + return { ...node, expanded: true, children } + } + if (node.children.length > 0) { + return { ...node, children: updateNode(node.children) } + } + return node + }) + } + return updateNode(prev) + }) + } catch (err) { + console.error("Failed to load directory:", err) + } + } + } + + function handleItemClick(item: FileExplorerItem) { + if (item.isDirectory) { + toggleExpand(item) + } + // Files are opened on double-click, not single click + } + + function handleItemDoubleClick(item: FileExplorerItem) { + if (!item.isDirectory) { + props.onFileOpen(item.path) + } + } + + function handleContextMenu(e: MouseEvent, item: FileExplorerItem) { + e.preventDefault() + e.stopPropagation() + setContextMenuItem(item) + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + } + + function closeContextMenu() { + setContextMenuPosition(null) + setContextMenuItem(null) + } + + function getParentPath(path: string): string { + const separator = path.includes("\\") ? "\\" : "/" + const parts = path.split(separator) + parts.pop() + return parts.join(separator) + } + + function getFileName(path: string): string { + const separator = path.includes("\\") ? "\\" : "/" + return path.split(separator).pop() || "" + } + + function joinPath(base: string, name: string): string { + const separator = base.includes("\\") ? "\\" : "/" + return `${base}${separator}${name}` + } + + async function handleRename(item: FileExplorerItem) { + closeContextMenu() + setRenamingPath(item.path) + setRenameValue(item.name) + // Focus the input after it renders + requestAnimationFrame(() => { + renameInputRef?.focus() + renameInputRef?.select() + }) + } + + async function submitRename(item: FileExplorerItem) { + const newName = renameValue().trim() + if (!newName || newName === item.name) { + setRenamingPath(null) + return + } + + if (!platform.renamePath) return + + const parentPath = getParentPath(item.path) + const newPath = joinPath(parentPath, newName) + + try { + await platform.renamePath(item.path, newPath) + await refreshTreePreservingState() + } catch (err) { + console.error("Failed to rename:", err) + } + + setRenamingPath(null) + } + + function handleDelete(item: FileExplorerItem) { + closeContextMenu() + if (!platform.deletePath) return + // Show confirmation dialog + setDeleteConfirm(item) + } + + async function confirmDelete() { + const item = deleteConfirm() + if (!item || !platform.deletePath) return + + try { + await platform.deletePath(item.path) + await refreshTreePreservingState() + } catch (err) { + console.error("Failed to delete:", err) + } + setDeleteConfirm(null) + } + + async function handleCopy(item: FileExplorerItem) { + closeContextMenu() + setClipboard({ path: item.path, operation: "copy" }) + } + + async function handleCut(item: FileExplorerItem) { + closeContextMenu() + setClipboard({ path: item.path, operation: "cut" }) + } + + async function handlePaste(targetDir: string) { + closeContextMenu() + const clip = clipboard() + if (!clip || !platform.copyPath || !platform.renamePath) return + + const fileName = getFileName(clip.path) + const destPath = joinPath(targetDir, fileName) + + try { + if (clip.operation === "copy") { + await platform.copyPath(clip.path, destPath) + } else { + await platform.renamePath(clip.path, destPath) + setClipboard(null) + } + await refreshTreePreservingState() + } catch (err) { + console.error("Failed to paste:", err) + } + } + + async function handleNewFile(parentPath: string, afterItemPath?: string) { + closeContextMenu() + // Make sure parent folder is expanded so input shows + await expandFolder(parentPath) + setShowNewFileInput({ parentPath, afterItemPath, type: "file" }) + setNewItemName("") + // Focus the input after it renders + requestAnimationFrame(() => { + newFileInputRef?.focus() + }) + } + + async function handleNewFolder(parentPath: string, afterItemPath?: string) { + closeContextMenu() + // Make sure parent folder is expanded so input shows + await expandFolder(parentPath) + setShowNewFileInput({ parentPath, afterItemPath, type: "directory" }) + setNewItemName("") + // Focus the input after it renders + requestAnimationFrame(() => { + newFileInputRef?.focus() + }) + } + + async function expandFolder(path: string) { + // Check if it's already expanded in the tree + const findAndExpand = async (nodes: FileExplorerItem[]): Promise => { + for (const node of nodes) { + if (node.path === path) { + if (!node.expanded) { + await loadChildren(path) + } + return true + } + if (node.children.length > 0 && await findAndExpand(node.children)) { + return true + } + } + return false + } + + // If path is project root, it's not in tree - just load children + if (path === projectRoot()) { + return + } + + await findAndExpand(tree()) + } + + async function submitNewItem() { + const input = showNewFileInput() + if (!input) return + + const name = newItemName().trim() + if (!name) { + setShowNewFileInput(null) + return + } + + const newPath = joinPath(input.parentPath, name) + + try { + if (input.type === "file" && platform.createFile) { + await platform.createFile(newPath) + } else if (input.type === "directory" && platform.createDirectory) { + await platform.createDirectory(newPath) + } + await refreshTreePreservingState() + } catch (err) { + console.error("Failed to create:", err) + } + + setShowNewFileInput(null) + } + + function TreeNode(nodeProps: { + item: FileExplorerItem + level: number + onToggle: (item: FileExplorerItem) => void + onClick: (item: FileExplorerItem) => void + onDoubleClick: (item: FileExplorerItem) => void + onContextMenu: (e: MouseEvent, item: FileExplorerItem) => void + }) { + const paddingLeft = () => `${nodeProps.level * 12 + 8}px` + const isRenaming = () => renamingPath() === nodeProps.item.path + const isCut = () => clipboard()?.path === nodeProps.item.path && clipboard()?.operation === "cut" + + return ( +
+
nodeProps.onClick(nodeProps.item)} + onDblClick={() => nodeProps.onDoubleClick(nodeProps.item)} + onContextMenu={(e) => nodeProps.onContextMenu(e, nodeProps.item)} + > + + + + +
+ + + setRenameValue(e.currentTarget.value)} + onBlur={() => submitRename(nodeProps.item)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + submitRename(nodeProps.item) + } else if (e.key === "Escape") { + setRenamingPath(null) + } + }} + class="flex-1 min-w-0 bg-surface-base border border-border-base rounded px-1 text-13-regular text-text-strong outline-none" + onClick={(e) => e.stopPropagation()} + onDblClick={(e) => e.stopPropagation()} + /> + } + > + {nodeProps.item.name} + +
+ 0}> + + {(child) => ( + + )} + + + {/* New file/folder input - show after this specific file */} + +
+
+ + setNewItemName(e.currentTarget.value)} + onBlur={() => submitNewItem()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + submitNewItem() + } else if (e.key === "Escape") { + setShowNewFileInput(null) + } + }} + placeholder={showNewFileInput()?.type === "directory" ? "New folder" : "New file"} + class="flex-1 min-w-0 bg-surface-base border border-border-base rounded px-1 text-13-regular text-text-strong outline-none" + /> +
+ + {/* New file/folder input - show inside this folder if it's a directory and no afterItemPath */} + +
+
+ + setNewItemName(e.currentTarget.value)} + onBlur={() => submitNewItem()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + submitNewItem() + } else if (e.key === "Escape") { + setShowNewFileInput(null) + } + }} + placeholder={showNewFileInput()?.type === "directory" ? "New folder" : "New file"} + class="flex-1 min-w-0 bg-surface-base border border-border-base rounded px-1 text-13-regular text-text-strong outline-none" + /> +
+ +
+ ) + } + + // Context menu component - use Show for reactivity + function ContextMenu() { + return ( + + {(_) => { + const item = contextMenuItem()! + const pos = contextMenuPosition()! + + const canPaste = () => { + const clip = clipboard() + return clip && item.isDirectory + } + + return ( + <> + {/* Backdrop to close menu when clicking outside */} +
closeContextMenu()} + onContextMenu={(e) => { + e.preventDefault() + closeContextMenu() + }} + /> +
e.stopPropagation()} + > +
+ {/* New File / New Folder */} + + +
+ {/* Open - only for files */} + + + + {/* Copy/Cut - only for non-root items */} + + + + + {/* Paste - only for directories */} + + + + {/* Rename/Delete - only for non-root items */} + +
+ + + +
+
+ + ) + }} + + ) + } + + // Close context menu when clicking outside + function handleDocumentClick(e: MouseEvent) { + // Don't close if clicking inside the context menu + const target = e.target as HTMLElement + if (target.closest('[data-context-menu]')) { + return + } + + if (contextMenuPosition()) { + closeContextMenu() + } + } + + // Root context menu (for creating files/folders at root level) + function handleRootContextMenu(e: MouseEvent) { + // Don't show root context menu if right-clicking on a tree item + const target = e.target as HTMLElement + if (target.closest('[data-tree-item]')) return + + const root = projectRoot() + if (!root) return + + e.preventDefault() + // Create a fake item for the root directory + setContextMenuItem({ + name: "", + path: root, + isDirectory: true, + expanded: true, + children: [], + }) + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + } + + return ( + <> +
+
+ + {/* New file/folder input at root level - only if no afterItemPath (meaning clicked on root/empty space) */} + +
+
+ + setNewItemName(e.currentTarget.value)} + onBlur={() => submitNewItem()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + submitNewItem() + } else if (e.key === "Escape") { + setShowNewFileInput(null) + } + }} + placeholder={showNewFileInput()?.type === "directory" ? "New folder" : "New file"} + class="flex-1 min-w-0 bg-surface-base border border-border-base rounded px-1 text-13-regular text-text-strong outline-none" + /> +
+ + + {(item) => ( + + )} + +
+ } + > +
No files found
+
+ } + > +
Loading...
+
+
+
+ {/* Render context menu in a portal to escape overflow containers */} + + + + {/* Delete confirmation dialog */} + + + {(item) => ( + <> +
setDeleteConfirm(null)} + /> +
+

Delete {item().isDirectory ? "folder" : "file"}?

+

+ Are you sure you want to delete "{item().name}"? + {item().isDirectory && " This will delete all contents."} +

+
+ + +
+
+ + )} + + + + ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4b7f9f4ad12..0111cd17174 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -5,8 +5,7 @@ import { useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" -// import { useServer } from "@/context/server" -// import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useEditor } from "@/context/editor" import { usePlatform } from "@/context/platform" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" @@ -46,10 +45,15 @@ export function SessionHeader() { const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") + // FORK: Editor context for file explorer/editor panel + const editorCtx = useEditor() + // Editor is only effectively visible when file system is supported + const editorEffectivelyVisible = createMemo(() => editorCtx.panelVisible() && !!platform.readFile) + // END FORK const showShare = createMemo(() => shareEnabled() && !!currentSession()) const showReview = createMemo(() => !!currentSession()) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - const view = createMemo(() => layout.view(sessionKey)) + const view = createMemo(() => layout.view(sessionKey())) const [state, setState] = createStore({ share: false, @@ -131,21 +135,74 @@ export function SessionHeader() { {(mount) => ( - + {(keybind) => {keybind()}} + + + + + + +
+ {/* END FORK */}
)} @@ -279,6 +336,7 @@ export function SessionHeader() {
+ {/* FORK: Review toggle with editor-aware icons (top-right quarter when editor visible) */}
+ {/* END FORK */}
)} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index cfb8a998d33..4aa17392bb3 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,9 +1,10 @@ -import { Component, createMemo, type JSX } from "solid-js" +import { Component, createMemo, Show, type JSX } from "solid-js" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useLanguage } from "@/context/language" import { useSettings, monoFontFamily } from "@/context/settings" +import { usePlatform } from "@/context/platform" import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" @@ -30,6 +31,7 @@ export const SettingsGeneral: Component = () => { const theme = useTheme() const language = useLanguage() const settings = useSettings() + const platform = usePlatform() const themeOptions = createMemo(() => Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), @@ -292,6 +294,26 @@ export const SettingsGeneral: Component = () => {
+ + {/* File Editor Section - only show on desktop */} + +
+

{language.t("settings.general.section.editor")}

+ +
+ + settings.editor.setAutoReloadBackgroundFiles(checked)} + /> + +
+
+
+
) diff --git a/packages/app/src/context/editor.tsx b/packages/app/src/context/editor.tsx new file mode 100644 index 00000000000..2e10967ceea --- /dev/null +++ b/packages/app/src/context/editor.tsx @@ -0,0 +1,144 @@ +import { createSignal, createMemo, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" + +export type EditorTab = { + path: string + isDirty: boolean + hasExternalChanges: boolean +} + +export type EditorState = { + // Tabs management + tabs: EditorTab[] + activeTab: Accessor + openFile: (path: string) => void + closeTab: (path: string) => void + closeOtherTabs: (path: string) => void + closeAllTabs: () => void + setActiveTab: (path: string) => void + setTabDirty: (path: string, isDirty: boolean) => void + setTabExternalChanges: (path: string, hasChanges: boolean) => void + getTab: (path: string) => EditorTab | undefined + // Legacy single-file API (for compatibility) + filePath: Accessor + isOpen: Accessor + close: () => void + // Panel visibility + panelVisible: Accessor + toggle: () => void + show: () => void + hide: () => void +} + +export const { use: useEditor, provider: EditorProvider } = createSimpleContext({ + name: "Editor", + init: (): EditorState => { + const [tabs, setTabs] = createStore([]) + const [activeTabPath, setActiveTabPath] = createSignal() + const [panelVisible, setPanelVisible] = createSignal(true) + + const activeTab = createMemo(() => activeTabPath()) + const filePath = createMemo(() => activeTabPath()) + const isOpen = createMemo(() => panelVisible() && !!activeTabPath()) + + const openFile = (path: string) => { + // Check if tab already exists + const existingIndex = tabs.findIndex((t) => t.path === path) + if (existingIndex >= 0) { + // Tab exists, just activate it + setActiveTabPath(path) + } else { + // Add new tab + setTabs([...tabs, { path, isDirty: false, hasExternalChanges: false }]) + setActiveTabPath(path) + } + setPanelVisible(true) + } + + const closeTab = (path: string) => { + const index = tabs.findIndex((t) => t.path === path) + if (index < 0) return + + const newTabs = tabs.filter((t) => t.path !== path) + setTabs(newTabs) + + // If closing the active tab, activate another one + if (activeTabPath() === path) { + if (newTabs.length > 0) { + // Activate the tab at the same index, or the last one + const newIndex = Math.min(index, newTabs.length - 1) + setActiveTabPath(newTabs[newIndex].path) + } else { + setActiveTabPath(undefined) + } + } + } + + const closeOtherTabs = (path: string) => { + const tab = tabs.find((t) => t.path === path) + if (tab) { + setTabs([tab]) + setActiveTabPath(path) + } + } + + const closeAllTabs = () => { + setTabs([]) + setActiveTabPath(undefined) + } + + const setActiveTab = (path: string) => { + if (tabs.some((t) => t.path === path)) { + setActiveTabPath(path) + } + } + + const setTabDirty = (path: string, isDirty: boolean) => { + const index = tabs.findIndex((t) => t.path === path) + if (index >= 0) { + setTabs(index, "isDirty", isDirty) + } + } + + const setTabExternalChanges = (path: string, hasChanges: boolean) => { + const index = tabs.findIndex((t) => t.path === path) + if (index >= 0) { + setTabs(index, "hasExternalChanges", hasChanges) + } + } + + const getTab = (path: string) => tabs.find((t) => t.path === path) + + const close = () => { + const path = activeTabPath() + if (path) closeTab(path) + } + + const toggle = () => setPanelVisible((v) => !v) + const show = () => setPanelVisible(true) + const hide = () => setPanelVisible(false) + + return { + get tabs() { + return tabs + }, + activeTab, + openFile, + closeTab, + closeOtherTabs, + closeAllTabs, + setActiveTab, + setTabDirty, + setTabExternalChanges, + getTab, + filePath, + isOpen, + close, + panelVisible, + toggle, + show, + hide, + } + }, +}) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 89056b2c845..87adfc9f24a 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -1,6 +1,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { AsyncStorage, SyncStorage } from "@solid-primitives/storage" +export type FileSystemEntry = { + name: string + path: string + isDirectory: boolean +} + export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" @@ -49,6 +55,41 @@ export type Platform = { /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ parseMarkdown?(markdown: string): Promise + + /** Get project root directory (desktop only) */ + getProjectRoot?(): Promise + + /** Read directory contents (desktop only) */ + readDirectory?(path: string): Promise + + /** Read file contents (desktop only) */ + readFile?(path: string): Promise + + /** Write file contents (desktop only) */ + writeFile?(path: string, contents: string): Promise + + /** Rename/move a file or directory (desktop only) */ + renamePath?(oldPath: string, newPath: string): Promise + + /** Delete a file or directory (desktop only) */ + deletePath?(path: string): Promise + + /** Copy a file or directory (desktop only) */ + copyPath?(source: string, destination: string): Promise + + /** Create a new file (desktop only) */ + createFile?(path: string): Promise + + /** Create a new directory (desktop only) */ + createDirectory?(path: string): Promise + + /** Watch a file for changes (desktop only) - returns an unwatch function */ + watchFile?(path: string, callback: (event: FileWatchEvent) => void): Promise<() => void> +} + +export type FileWatchEvent = { + type: "create" | "modify" | "remove" | "rename" | "any" + paths: string[] } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index d976cbc4960..547f714925e 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -15,6 +15,11 @@ export interface SoundSettings { errors: string } +export interface EditorSettings { + pollInterval: number + autoReloadBackgroundFiles: boolean +} + export interface Settings { general: { autoSave: boolean @@ -29,6 +34,7 @@ export interface Settings { } notifications: NotificationSettings sounds: SoundSettings + editor: EditorSettings } const defaultSettings: Settings = { @@ -53,6 +59,10 @@ const defaultSettings: Settings = { permissions: "staplebops-02", errors: "nope-03", }, + editor: { + pollInterval: 1000, + autoReloadBackgroundFiles: true, + }, } const monoFallback = @@ -154,6 +164,16 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("sounds", "errors", value) }, }, + editor: { + pollInterval: createMemo(() => store.editor?.pollInterval ?? defaultSettings.editor.pollInterval), + setPollInterval(value: number) { + setStore("editor", "pollInterval", value) + }, + autoReloadBackgroundFiles: createMemo(() => store.editor?.autoReloadBackgroundFiles ?? defaultSettings.editor.autoReloadBackgroundFiles), + setAutoReloadBackgroundFiles(value: boolean) { + setStore("editor", "autoReloadBackgroundFiles", value) + }, + }, } }, }) diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebd..49ec4449fa2 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1,17 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. + */ + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 3a1912345d2..b54f824b616 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -43,6 +43,7 @@ export const dict = { "command.file.open": "فتح ملف", "command.file.open.description": "البحث في الملفات والأوامر", "command.terminal.toggle": "تبديل المحطة الطرفية", + "command.editor.toggle": "تبديل المحرر", "command.review.toggle": "تبديل المراجعة", "command.terminal.new": "محطة طرفية جديدة", "command.terminal.new.description": "إنشاء علامة تبويب جديدة للمحطة الطرفية", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 7e1262a932c..55721d02b24 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -43,6 +43,7 @@ export const dict = { "command.file.open": "Abrir arquivo", "command.file.open.description": "Buscar arquivos e comandos", "command.terminal.toggle": "Alternar terminal", + "command.editor.toggle": "Alternar editor", "command.review.toggle": "Alternar revisão", "command.terminal.new": "Novo terminal", "command.terminal.new.description": "Criar uma nova aba de terminal", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 863e7905eec..b2bd3f4864b 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -41,6 +41,7 @@ export const dict = { "command.file.open": "Åbn fil", "command.file.open.description": "Søg i filer og kommandoer", "command.terminal.toggle": "Skift terminal", + "command.editor.toggle": "Skift editor", "command.review.toggle": "Skift gennemgang", "command.terminal.new": "Ny terminal", "command.terminal.new.description": "Opret en ny terminalfane", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index ca926703f9e..bbf2dc28850 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -45,6 +45,7 @@ export const dict = { "command.file.open": "Datei öffnen", "command.file.open.description": "Dateien und Befehle durchsuchen", "command.terminal.toggle": "Terminal umschalten", + "command.editor.toggle": "Editor umschalten", "command.review.toggle": "Überprüfung umschalten", "command.terminal.new": "Neues Terminal", "command.terminal.new.description": "Neuen Terminal-Tab erstellen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 5dd1ac5f342..3db9570e37f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -43,6 +43,7 @@ export const dict = { "command.file.open": "Open file", "command.file.open.description": "Search files and commands", "command.terminal.toggle": "Toggle terminal", + "command.editor.toggle": "Toggle editor", "command.review.toggle": "Toggle review", "command.terminal.new": "New terminal", "command.terminal.new.description": "Create a new terminal tab", @@ -489,6 +490,10 @@ export const dict = { "settings.general.section.appearance": "Appearance", "settings.general.section.notifications": "System notifications", "settings.general.section.sounds": "Sound effects", + "settings.general.section.editor": "File Editor", + + "settings.general.editor.autoReloadBackgroundFiles.title": "Auto-reload background files", + "settings.general.editor.autoReloadBackgroundFiles.description": "Automatically reload inactive tabs when files change on disk (e.g., edited by AI). When off, you'll be prompted to reload or keep your version.", "settings.general.row.language.title": "Language", "settings.general.row.language.description": "Change the display language for OpenCode", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 8eaa30daf0a..474ff121adb 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -41,6 +41,7 @@ export const dict = { "command.file.open": "Abrir archivo", "command.file.open.description": "Buscar archivos y comandos", "command.terminal.toggle": "Alternar terminal", + "command.editor.toggle": "Alternar editor", "command.review.toggle": "Alternar revisión", "command.terminal.new": "Nueva terminal", "command.terminal.new.description": "Crear una nueva pestaña de terminal", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 16aba386b96..adef399cc05 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -41,6 +41,7 @@ export const dict = { "command.file.open": "Ouvrir un fichier", "command.file.open.description": "Rechercher des fichiers et des commandes", "command.terminal.toggle": "Basculer le terminal", + "command.editor.toggle": "Basculer l'éditeur", "command.review.toggle": "Basculer la revue", "command.terminal.new": "Nouveau terminal", "command.terminal.new.description": "Créer un nouvel onglet de terminal", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index d33d5c7a757..d79dca846ad 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -41,6 +41,7 @@ export const dict = { "command.file.open": "ファイルを開く", "command.file.open.description": "ファイルとコマンドを検索", "command.terminal.toggle": "ターミナルの切り替え", + "command.editor.toggle": "エディターの切り替え", "command.review.toggle": "レビューの切り替え", "command.terminal.new": "新しいターミナル", "command.terminal.new.description": "新しいターミナルタブを作成", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 73d0f96873f..712622f8b5c 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -45,6 +45,7 @@ export const dict = { "command.file.open": "파일 열기", "command.file.open.description": "파일 및 명령어 검색", "command.terminal.toggle": "터미널 토글", + "command.editor.toggle": "편집기 토글", "command.review.toggle": "검토 토글", "command.terminal.new": "새 터미널", "command.terminal.new.description": "새 터미널 탭 생성", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 133f60aed90..babc132cf35 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -46,6 +46,7 @@ export const dict = { "command.file.open": "Åpne fil", "command.file.open.description": "Søk i filer og kommandoer", "command.terminal.toggle": "Veksle terminal", + "command.editor.toggle": "Veksle redigering", "command.review.toggle": "Veksle gjennomgang", "command.terminal.new": "Ny terminal", "command.terminal.new.description": "Opprett en ny terminalfane", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 46b783082d3..9ed5580b635 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -43,6 +43,7 @@ export const dict = { "command.file.open": "Otwórz plik", "command.file.open.description": "Szukaj plików i poleceń", "command.terminal.toggle": "Przełącz terminal", + "command.editor.toggle": "Przełącz edytor", "command.review.toggle": "Przełącz przegląd", "command.terminal.new": "Nowy terminal", "command.terminal.new.description": "Utwórz nową kartę terminala", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 602ff208235..059a6f688a0 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -43,6 +43,7 @@ export const dict = { "command.file.open": "Открыть файл", "command.file.open.description": "Поиск файлов и команд", "command.terminal.toggle": "Переключить терминал", + "command.editor.toggle": "Переключить редактор", "command.review.toggle": "Переключить обзор", "command.terminal.new": "Новый терминал", "command.terminal.new.description": "Создать новую вкладку терминала", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 4777ca665ca..a349bbc1211 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -45,6 +45,7 @@ export const dict = { "command.file.open": "打开文件", "command.file.open.description": "搜索文件和命令", "command.terminal.toggle": "切换终端", + "command.editor.toggle": "切换编辑器", "command.review.toggle": "切换审查", "command.terminal.new": "新建终端", "command.terminal.new.description": "创建新的终端标签页", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 5c1b2d64516..b6325809f2e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -45,6 +45,7 @@ export const dict = { "command.file.open": "開啟檔案", "command.file.open.description": "搜尋檔案和命令", "command.terminal.toggle": "切換終端機", + "command.editor.toggle": "切換編輯器", "command.review.toggle": "切換審查", "command.terminal.new": "新增終端機", "command.terminal.new.description": "建立新的終端機標籤頁", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f14b2e64dcf..5bfd6dd407c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -57,6 +57,9 @@ import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" import { Worktree as WorktreeState } from "@/utils/worktree" +import { ExplorerPanel } from "@/components/explorer-panel" +import { EditorPanel } from "@/components/editor-panel" +import { useEditor } from "@/context/editor" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -112,6 +115,8 @@ export default function Layout(props: ParentProps) { const command = useCommand() const theme = useTheme() const language = useLanguage() + const editorCtx = useEditor() + const [editorPanelWidth, setEditorPanelWidth] = createSignal(Math.floor(window.innerWidth * 0.4)) // pixels const initialDir = params.dir const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] @@ -2251,6 +2256,8 @@ export default function Layout(props: ParentProps) { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() const sync = useGlobalSync() + const editor = useEditor() + const [explorerHeight, setExplorerHeight] = createSignal(400) const project = createMemo(() => currentProject()) const projectName = createMemo(() => { const current = project() @@ -2457,6 +2464,23 @@ export default function Layout(props: ParentProps) { when={layout.sidebar.workspaces(p.worktree)()} fallback={ <> + +
+ editorCtx.openFile(path)} + class="flex-1 min-h-0 overflow-y-auto px-3" + /> + +
+
<> + +
+
Explorer
+ editorCtx.openFile(path)} + class="flex-1 min-h-0 overflow-y-auto px-3" + /> + +
+
}> - {props.children} + +
+ + +
+
+
{props.children}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5bf337bfaa4..d4281526a35 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -68,6 +68,7 @@ import { NewSessionView, } from "@/components/session" import { usePlatform } from "@/context/platform" +import { useEditor } from "@/context/editor" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" @@ -193,6 +194,7 @@ export default function Page() { const command = useCommand() const language = useLanguage() const platform = usePlatform() + const editorCtx = useEditor() const params = useParams() const navigate = useNavigate() const sdk = useSDK() @@ -200,9 +202,10 @@ export default function Page() { const comments = useComments() const permission = usePermission() const [pendingMessage, setPendingMessage] = createSignal(undefined) + const [inlineReviewHeight, setInlineReviewHeight] = createSignal(250) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) + const tabs = createMemo(() => layout.tabs(sessionKey())) + const view = createMemo(() => layout.view(sessionKey())) if (import.meta.env.DEV) { createEffect( @@ -615,6 +618,14 @@ export default function Page() { slash: "terminal", onSelect: () => view().terminal.toggle(), }, + { + id: "editor.toggle", + title: language.t("command.editor.toggle"), + description: "", + category: "View", + keybind: "mod+shift+e", + onSelect: () => editorCtx.toggle(), + }, { id: "review.toggle", title: "Toggle review", @@ -1444,13 +1455,65 @@ export default function Page() {
+ {/* Inline review panel - shown when editor panel is visible (desktop only, with file system support) */} + +
+
+ + + Loading changes...
} + > + { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + classes={{ + header: "px-4", + container: "px-4", + }} + /> + + + +
+ +
No changes yet
+
+
+ +
+ +
+
@@ -1691,7 +1754,7 @@ export default function Page() {
- + - {/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */} - + {/* Desktop tabs panel (Review + Context + Files) - hidden on mobile and when editor panel is visible (with file system support) */} +