diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 01d6d6058..46f2a3b24 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -34,6 +34,12 @@ A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; + A5001422 /* EditorPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500141A /* EditorPanel.swift */; }; + A5001423 /* EditorPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500141B /* EditorPanelView.swift */; }; + EXP00001 /* ExplorerSidebarPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EXP00011 /* ExplorerSidebarPanel.swift */; }; + EXP00002 /* ExplorerSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EXP00012 /* ExplorerSidebarView.swift */; }; + A5001424 /* editor in Resources */ = {isa = PBXBuildFile; fileRef = A500141C /* editor */; }; + EXP00003 /* explorer in Resources */ = {isa = PBXBuildFile; fileRef = EXP00013 /* explorer */; }; A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; @@ -123,7 +129,11 @@ CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; - /* End PBXBuildFile section */ + MWP00001 /* MonacoWebViewPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = MWP00011 /* MonacoWebViewPool.swift */; }; + NFE00001 /* NativeFileExplorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NFE00011 /* NativeFileExplorer.swift */; }; + FSV00001 /* FileSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FSV00011 /* FileSearchView.swift */; }; + STS00001 /* SidebarTabSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = STS00011 /* SidebarTabSelector.swift */; }; + /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ A5001020 /* Embed Frameworks */ = { @@ -210,6 +220,12 @@ A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; + A500141A /* EditorPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/EditorPanel.swift; sourceTree = ""; }; + A500141B /* EditorPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/EditorPanelView.swift; sourceTree = ""; }; + EXP00011 /* ExplorerSidebarPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ExplorerSidebarPanel.swift; sourceTree = ""; }; + EXP00012 /* ExplorerSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ExplorerSidebarView.swift; sourceTree = ""; }; + A500141C /* editor */ = {isa = PBXFileReference; lastKnownFileType = folder; path = editor; sourceTree = ""; }; + EXP00013 /* explorer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = explorer; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -292,6 +308,10 @@ EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; + MWP00011 /* MonacoWebViewPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonacoWebViewPool.swift; sourceTree = ""; }; + NFE00011 /* NativeFileExplorer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeFileExplorer.swift; sourceTree = ""; }; + FSV00011 /* FileSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchView.swift; sourceTree = ""; }; + STS00011 /* SidebarTabSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabSelector.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -342,6 +362,8 @@ DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */, A5001623 /* cmux.sdef in Resources */, + A5001424 /* editor in Resources */, + EXP00003 /* explorer in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -439,6 +461,10 @@ A5007421 /* BrowserPopupWindowController.swift */, A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, + A500141A /* EditorPanel.swift */, + A500141B /* EditorPanelView.swift */, + EXP00011 /* ExplorerSidebarPanel.swift */, + EXP00012 /* ExplorerSidebarView.swift */, A5001510 /* CmuxWebView.swift */, A5001415 /* PanelContentView.swift */, A5001211 /* UpdateController.swift */, @@ -458,6 +484,10 @@ A5001222 /* WindowAccessor.swift */, A5001611 /* SessionPersistence.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, + MWP00011 /* MonacoWebViewPool.swift */, + NFE00011 /* NativeFileExplorer.swift */, + FSV00011 /* FileSearchView.swift */, + STS00011 /* SidebarTabSelector.swift */, ); path = Sources; sourceTree = ""; @@ -479,6 +509,8 @@ DA7A10CA710E000000000001 /* Localizable.xcstrings */, DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, A5001622 /* cmux.sdef */, + A500141C /* editor */, + EXP00013 /* explorer */, ); path = Resources; sourceTree = ""; @@ -734,6 +766,10 @@ A5007420 /* BrowserPopupWindowController.swift in Sources */, A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, + A5001422 /* EditorPanel.swift in Sources */, + A5001423 /* EditorPanelView.swift in Sources */, + EXP00001 /* ExplorerSidebarPanel.swift in Sources */, + EXP00002 /* ExplorerSidebarView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, A5001405 /* PanelContentView.swift in Sources */, A5001201 /* UpdateController.swift in Sources */, @@ -753,6 +789,10 @@ A500120C /* WindowAccessor.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, + MWP00001 /* MonacoWebViewPool.swift in Sources */, + NFE00001 /* NativeFileExplorer.swift in Sources */, + FSV00001 /* FileSearchView.swift in Sources */, + STS00001 /* SidebarTabSelector.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/editor/editor.js b/Resources/editor/editor.js new file mode 100644 index 000000000..12d2dc631 --- /dev/null +++ b/Resources/editor/editor.js @@ -0,0 +1,220 @@ +// cmux Editor — single-file Monaco editor +// Swift injects Monaco paths via window.cmux.initMonaco(vsPath, cssPath) +// Swift injects file content directly via window.cmux.openFileWithContent() + +(function () { + 'use strict'; + + let editor = null; + let monacoInstance = null; + let currentFilePath = null; + let originalContent = ''; + let isDirty = false; + let requestCounter = 0; + const pendingRequests = new Map(); + + // ── Swift Bridge ─────────────────────────────────────────────────── + window.cmux = { + handleResponse(requestId, data) { + const p = pendingRequests.get(requestId); + if (!p) return; + pendingRequests.delete(requestId); + if (typeof data === 'string') { + try { p.resolve(JSON.parse(data)); } catch { p.resolve(data); } + } else { + p.resolve(data); + } + }, + handleError(requestId, message) { + const p = pendingRequests.get(requestId); + if (!p) return; + pendingRequests.delete(requestId); + p.reject(new Error(message)); + }, + updateMonacoTheme(editorBg, editorFg) { + if (!monacoInstance || !editor) return; + monacoInstance.editor.defineTheme('cmux-dark', { + base: 'vs-dark', inherit: true, rules: [], + colors: { + 'editor.background': editorBg, + 'editorGutter.background': editorBg, + 'editor.lineHighlightBackground': editorBg + '22', + 'editorLineNumber.foreground': editorFg + '55', + 'editorLineNumber.activeForeground': editorFg + 'cc' + } + }); + monacoInstance.editor.setTheme('cmux-dark'); + document.documentElement.style.setProperty('--editor-bg', editorBg); + }, + + // Called from Swift with file content already read — zero bridge round-trips + openFileWithContent(relativePath, fileName, content) { + if (!editor || !monacoInstance) { + window.cmux._pendingOpen = { relativePath, fileName, content }; + return; + } + doOpenFileWithContent(relativePath, fileName, content); + }, + + // Called from Swift when file is too large + showLargeFile(fileName, reason) { + currentFilePath = null; + if (editor) editor.setModel(null); + showLargeFileNotice(fileName, reason); + notifyActive(fileName); + }, + + // Called from Swift with Monaco paths — triggers init + initMonaco(vsPath, cssHref) { + if (cssHref) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = cssHref; + document.head.appendChild(link); + } + const script = document.createElement('script'); + script.onload = function() { bootstrapMonaco(vsPath); }; + script.src = vsPath + '/loader.js'; + document.head.appendChild(script); + } + }; + + function showLargeFileNotice(fileName, reason) { + document.getElementById('editor-container').style.display = 'none'; + const notice = document.getElementById('large-file-notice'); + document.getElementById('large-file-name').textContent = fileName; + document.getElementById('large-file-message').textContent = reason; + notice.classList.add('visible'); + } + + function hideLargeFileNotice() { + document.getElementById('large-file-notice').classList.remove('visible'); + document.getElementById('editor-container').style.display = ''; + } + + function doOpenFileWithContent(relativePath, fileName, content) { + hideLargeFileNotice(); + currentFilePath = relativePath; + originalContent = content; + isDirty = false; + const lang = getLang(fileName); + const model = monacoInstance.editor.createModel(content, lang); + const oldModel = editor.getModel(); + editor.setModel(model); + if (oldModel) oldModel.dispose(); + model.onDidChangeContent(() => { + const nowDirty = model.getValue() !== originalContent; + if (nowDirty !== isDirty) { + isDirty = nowDirty; + notifyDirty(isDirty); + } + }); + notifyActive(fileName); + } + + // Bridge for save — writeFile still needs async round-trip + function post(action, params = {}) { + return new Promise((resolve, reject) => { + const requestId = 'r' + (++requestCounter); + pendingRequests.set(requestId, { resolve, reject }); + window.webkit.messageHandlers.cmuxEditor.postMessage({ action, requestId, ...params }); + }); + } + + const writeFile = (path, content) => post('writeFile', { path, content }); + + function notifyDirty(d) { + window.webkit.messageHandlers.cmuxEditor.postMessage({ action: 'dirtyState', isDirty: d }); + } + function notifyActive(n) { + window.webkit.messageHandlers.cmuxEditor.postMessage({ action: 'activeFile', fileName: n || null }); + } + function notifyReady() { + window.webkit.messageHandlers.cmuxEditor.postMessage({ action: 'editorReady' }); + } + + function getLang(name) { + const ext = (name || '').split('.').pop().toLowerCase(); + const m = { + js:'javascript',jsx:'javascript',mjs:'javascript',cjs:'javascript', + ts:'typescript',tsx:'typescript', + py:'python',rb:'ruby',rs:'rust',go:'go',java:'java',kt:'kotlin', + c:'c',h:'c',cpp:'cpp',hpp:'cpp',cs:'csharp',swift:'swift', + html:'html',htm:'html',css:'css',scss:'scss',less:'less', + json:'json',yaml:'yaml',yml:'yaml',xml:'xml',svg:'xml', + md:'markdown',sh:'shell',bash:'shell',zsh:'shell',fish:'shell', + sql:'sql',toml:'ini',ini:'ini',dockerfile:'dockerfile', + lua:'lua',php:'php',r:'r',zig:'zig' + }; + return m[ext] || 'plaintext'; + } + + async function saveActive() { + if (!currentFilePath || !editor) return; + const content = editor.getModel().getValue(); + try { + await writeFile(currentFilePath, content); + originalContent = content; + isDirty = false; + notifyDirty(false); + } catch (err) { console.error('Save failed:', err); } + } + + function bootstrapMonaco(vsPath) { + require.config({ paths: { vs: vsPath } }); + + require(['vs/editor/editor.main'], async function (monaco) { + monacoInstance = monaco; + + monaco.editor.defineTheme('cmux-dark', { + base: 'vs-dark', inherit: true, rules: [], + colors: { + 'editor.background': '#1f1f1f', + 'editorGutter.background': '#1f1f1f', + 'editor.lineHighlightBackground': '#2a2d2e', + 'editorLineNumber.foreground': '#5a5a5a', + 'editorLineNumber.activeForeground': '#c6c6c6' + } + }); + + editor = monaco.editor.create(document.getElementById('editor-container'), { + theme: 'cmux-dark', + fontSize: 13, + fontFamily: "'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + automaticLayout: true, + wordWrap: 'off', + renderWhitespace: 'selection', + lineNumbers: 'on', + roundedSelection: false, + cursorBlinking: 'smooth', + smoothScrolling: true, + padding: { top: 8, bottom: 8 }, + overviewRulerBorder: false, + bracketPairColorization: { enabled: true }, + guides: { indentation: true, bracketPairs: true }, + stickyScroll: { enabled: true } + }); + + // Cmd+S — save + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveActive()); + + // Disable Monaco shortcuts that conflict with cmux + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, () => {}); + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyP, () => {}); + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyP, () => {}); + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyN, () => {}); + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyN, () => {}); + + notifyReady(); + + // Open any file that was queued before Monaco was ready + if (window.cmux._pendingOpen) { + const pending = window.cmux._pendingOpen; + delete window.cmux._pendingOpen; + doOpenFileWithContent(pending.relativePath, pending.fileName, pending.content); + } + }); + } +})(); diff --git a/Resources/editor/index.html b/Resources/editor/index.html new file mode 100644 index 000000000..9c31603dc --- /dev/null +++ b/Resources/editor/index.html @@ -0,0 +1,32 @@ + + + + + + cmux Editor + + + +
+
+
+
+
+ + + diff --git a/Resources/explorer/explorer.css b/Resources/explorer/explorer.css new file mode 100644 index 000000000..0f03822b9 --- /dev/null +++ b/Resources/explorer/explorer.css @@ -0,0 +1,211 @@ +/* cmux Sidebar Explorer — file tree only */ +@import url('https://cdn.jsdelivr.net/npm/@vscode/codicons@0.0.36/dist/codicon.css'); + +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --sidebar-bg: #181818; + --sidebar-fg: #cccccc; + --sidebar-border: #2b2b2b; + --sidebar-header-bg: #181818; + --list-hover-bg: #2a2d2e; + --list-active-selection-bg: #094771; + --list-active-selection-fg: #ffffff; + --list-inactive-selection-bg: #37373d; + --list-focus-outline: #007fd4; + --tree-indent-guide: #585858; + --tree-row-height: 22px; + --git-modified: #e2c08d; + --git-added: #81b88b; + --git-deleted: #c74e39; + --git-untracked: #73c991; + --git-renamed: #73c991; + --git-ignored: #8c8c8c; + --git-conflict: #e4676b; + --focus-border: #0078d4; + --scrollbar-thumb: rgba(121,121,121,0.4); + --input-bg: #313131; + --input-border: #3c3c3c; + --input-fg: #cccccc; + --context-menu-bg: #1f1f1f; + --context-menu-hover: #094771; +} + +html, body { + height: 100%; + overflow: hidden; + background: var(--sidebar-bg); + color: var(--sidebar-fg); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 13px; + -webkit-user-select: none; + user-select: none; +} + +#app { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +#sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 12px; + height: 35px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--sidebar-fg); + flex-shrink: 0; +} + +.header-buttons { display: flex; gap: 0; } + +.header-btn { + background: none; + border: none; + color: var(--sidebar-fg); + opacity: 0.7; + cursor: pointer; + font-family: 'codicon'; + font-size: 16px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + line-height: 1; +} +.header-btn:hover { opacity: 1; background: var(--list-hover-bg); } + +#file-tree { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +#file-tree::-webkit-scrollbar { width: 10px; background: transparent; } +#file-tree::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 0; min-height: 40px; } +#file-tree::-webkit-scrollbar-thumb:hover { background: rgba(121,121,121,0.7); } + +.tree-row { + display: flex; + align-items: center; + height: var(--tree-row-height); + line-height: var(--tree-row-height); + cursor: pointer; + padding-right: 8px; + position: relative; + white-space: nowrap; +} +.tree-row:hover { background: var(--list-hover-bg); } +.tree-row.selected { background: var(--list-inactive-selection-bg); } +.tree-row.selected.focused { background: var(--list-active-selection-bg); color: var(--list-active-selection-fg); } + +.tree-indent { display: inline-flex; height: 100%; flex-shrink: 0; } +.indent-guide { display: inline-block; width: 8px; height: 100%; border-right: 1px solid transparent; box-sizing: border-box; } +.indent-guide.active { border-right-color: var(--tree-indent-guide); } + +.tree-twistie { + width: 16px; + height: var(--tree-row-height); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-family: 'codicon'; + font-size: 16px; + color: var(--sidebar-fg); + transition: transform 0.1s ease; +} +.tree-twistie.expanded { transform: rotate(90deg); } +.tree-twistie.hidden { visibility: hidden; } + +.tree-icon { + width: 16px; height: 16px; + margin-right: 6px; flex-shrink: 0; + font-family: 'codicon'; font-size: 16px; + display: flex; align-items: center; justify-content: center; + line-height: 1; +} + +.tree-icon.icon-folder { color: #dcb67a; } +.tree-icon.icon-folder-open { color: #dcb67a; } +.tree-icon.icon-file { color: var(--sidebar-fg); opacity: 0.7; } +.tree-icon.icon-js { color: #e6cd69; } +.tree-icon.icon-ts { color: #3178c6; } +.tree-icon.icon-json { color: #e6cd69; } +.tree-icon.icon-html { color: #e34c26; } +.tree-icon.icon-css { color: #563d7c; } +.tree-icon.icon-md { color: #519aba; } +.tree-icon.icon-py { color: #3572a5; } +.tree-icon.icon-rs { color: #dea584; } +.tree-icon.icon-go { color: #00add8; } +.tree-icon.icon-swift { color: #f05138; } +.tree-icon.icon-rb { color: #cc342d; } +.tree-icon.icon-java { color: #b07219; } +.tree-icon.icon-c { color: #555555; } +.tree-icon.icon-cpp { color: #f34b7d; } +.tree-icon.icon-sh { color: #89e051; } +.tree-icon.icon-yml { color: #cb171e; } +.tree-icon.icon-toml { color: #9c4221; } +.tree-icon.icon-lock { color: #8c8c8c; opacity: 0.5; } +.tree-icon.icon-image { color: #a074c4; } +.tree-icon.icon-zig { color: #f7a41d; } + +.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; font-size: 13px; line-height: var(--tree-row-height); } +.tree-label.git-modified { color: var(--git-modified); } +.tree-label.git-added { color: var(--git-added); } +.tree-label.git-deleted { color: var(--git-deleted); text-decoration: line-through; } +/* Folders never get strikethrough even if they contain deleted files */ +.tree-row[data-is-dir="1"] .tree-label.git-deleted { text-decoration: none; } +.tree-label.git-untracked { color: var(--git-untracked); } +.tree-label.git-renamed { color: var(--git-renamed); } +.tree-label.git-conflict { color: var(--git-conflict); } +.tree-label.git-ignored { color: var(--git-ignored); opacity: 0.4; } + +.tree-badge { margin-left: auto; padding: 0 5px; font-size: 11px; font-weight: 600; border-radius: 3px; flex-shrink: 0; line-height: 16px; min-width: 16px; text-align: center; } +.tree-badge.badge-M { color: var(--git-modified); } +.tree-badge.badge-A { color: var(--git-added); } +.tree-badge.badge-D { color: var(--git-deleted); } +.tree-badge.badge-U { color: var(--git-untracked); } +.tree-badge.badge-R { color: var(--git-renamed); } +.tree-badge.badge-C { color: var(--git-conflict); } +.tree-badge.badge-I { color: var(--git-ignored); } +.tree-badge.badge-dot { width: 6px; height: 6px; min-width: 6px; padding: 0; border-radius: 50%; margin-left: 6px; align-self: center; } + +.tree-children { display: none; } +.tree-children.expanded { display: block; } + +/* Root folder headers in multi-root mode */ +.root-header { font-weight: 600; } +.root-header .tree-label { text-transform: uppercase; font-size: 11px; letter-spacing: 0.3px; } +.root-section { margin-bottom: 2px; } + +.context-menu { + position: fixed; + background: var(--context-menu-bg); + border: 1px solid var(--sidebar-border); + border-radius: 4px; + padding: 4px 0; + min-width: 180px; + box-shadow: 0 2px 8px rgba(0,0,0,0.5); + z-index: 1000; + font-size: 13px; +} +.context-menu-item { padding: 4px 24px 4px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; height: 26px; } +.context-menu-item:hover { background: var(--context-menu-hover); color: #fff; } +.context-menu-item .shortcut { margin-left: auto; font-size: 11px; opacity: 0.6; } +.context-menu-separator { height: 1px; background: var(--sidebar-border); margin: 4px 0; } + +.tree-inline-input { + width: calc(100% - 8px); margin: 0 4px; padding: 0 4px; height: 20px; + background: var(--input-bg); border: 1px solid var(--focus-border); border-radius: 2px; + color: var(--input-fg); font-size: 13px; font-family: inherit; outline: none; line-height: 20px; +} +.tree-input-row { height: var(--tree-row-height); display: flex; align-items: center; padding: 0 !important; } diff --git a/Resources/explorer/explorer.js b/Resources/explorer/explorer.js new file mode 100644 index 000000000..60ec654ec --- /dev/null +++ b/Resources/explorer/explorer.js @@ -0,0 +1,686 @@ +// cmux Sidebar Explorer — multi-root file tree with external file opening +// Swift bridge via window.webkit.messageHandlers.cmuxExplorer + +(function () { + 'use strict'; + + let requestCounter = 0; + const pendingRequests = new Map(); + let roots = []; // [{name, rootIndex}] + let gitStatusMaps = new Map(); // rootIndex -> Map(path -> status) + let gitIgnoredSets = new Map(); // rootIndex -> Set(path) + let selectedTreePaths = new Set(); + let lastClickedPath = null; + let selectedTreePath = null; + let inlineInputActive = false; + + // ── Swift Bridge ─────────────────────────────────────────────────── + window.cmuxExplorer = { + handleResponse(requestId, data) { + const p = pendingRequests.get(requestId); + if (!p) return; + pendingRequests.delete(requestId); + if (typeof data === 'string') { + try { p.resolve(JSON.parse(data)); } catch { p.resolve(data); } + } else { + p.resolve(data); + } + }, + handleError(requestId, message) { + const p = pendingRequests.get(requestId); + if (!p) return; + pendingRequests.delete(requestId); + p.reject(new Error(message)); + }, + updateTheme(sidebarBg, fg, borderColor, hoverBg, selectedBg, indentGuide) { + const r = document.documentElement.style; + r.setProperty('--sidebar-bg', sidebarBg); + r.setProperty('--sidebar-fg', fg); + r.setProperty('--sidebar-border', borderColor); + r.setProperty('--sidebar-header-bg', sidebarBg); + r.setProperty('--list-hover-bg', hoverBg); + r.setProperty('--list-inactive-selection-bg', selectedBg); + r.setProperty('--tree-indent-guide', indentGuide); + r.setProperty('--input-bg', hoverBg); + r.setProperty('--input-border', borderColor); + r.setProperty('--input-fg', fg); + r.setProperty('--context-menu-bg', sidebarBg); + document.body.style.background = sidebarBg; + }, + // Called from Swift to set/update the root folders + setRoots(newRoots) { + // newRoots = [{name: "cmux", rootIndex: 0}, {name: "web", rootIndex: 1}] + roots = newRoots; + window.cmuxExplorer._lastRoots = newRoots; + fullRefresh(); + }, + // Called from Swift on FSEvents file change — diffed, no flash + refresh() { + if (roots.length > 0) diffRefresh(); + } + }; + + function post(action, params = {}) { + return new Promise((resolve, reject) => { + const requestId = 'r' + (++requestCounter); + pendingRequests.set(requestId, { resolve, reject }); + window.webkit.messageHandlers.cmuxExplorer.postMessage({ action, requestId, ...params }); + }); + } + + const readDir = (rootIndex, path) => post('readDir', { rootIndex, path }); + const createFile = (rootIndex, path) => post('createFile', { rootIndex, path }); + const createDir = (rootIndex, path) => post('createDir', { rootIndex, path }); + const deleteFile = (rootIndex, path) => post('deleteFile', { rootIndex, path }); + const renameFile = (rootIndex, oldPath, newPath) => post('renameFile', { rootIndex, oldPath, newPath }); + const getGitStatus = (rootIndex) => post('gitStatus', { rootIndex }); + + function openFileExternal(rootIndex, path) { + window.webkit.messageHandlers.cmuxExplorer.postMessage({ + action: 'openFileExternal', rootIndex, path + }); + } + + function pinFileExternal(rootIndex, path) { + window.webkit.messageHandlers.cmuxExplorer.postMessage({ + action: 'pinFileExternal', rootIndex, path + }); + } + + // ── File Icons ───────────────────────────────────────────────────── + function fileIconClass(name, isDir, isOpen) { + if (isDir) return isOpen ? 'icon-folder-open' : 'icon-folder'; + const ext = name.split('.').pop().toLowerCase(); + const m = { + js:'icon-js',jsx:'icon-js',mjs:'icon-js',cjs:'icon-js',ts:'icon-ts',tsx:'icon-ts', + json:'icon-json',html:'icon-html',htm:'icon-html',css:'icon-css',scss:'icon-css', + md:'icon-md',py:'icon-py',rs:'icon-rs',go:'icon-go',swift:'icon-swift', + rb:'icon-rb',java:'icon-java',c:'icon-c',cpp:'icon-cpp',h:'icon-c',hpp:'icon-cpp', + sh:'icon-sh',bash:'icon-sh',zsh:'icon-sh',yml:'icon-yml',yaml:'icon-yml', + toml:'icon-toml',zig:'icon-zig', + png:'icon-image',jpg:'icon-image',jpeg:'icon-image',gif:'icon-image',svg:'icon-image', + lock:'icon-lock' + }; + return m[ext] || 'icon-file'; + } + + function fileCodiconChar(name, isDir, isOpen) { + if (isDir) return isOpen ? '\uEAF7' : '\uEAF6'; + return '\uEB60'; + } + + // ── Git Status ───────────────────────────────────────────────────── + async function refreshGitStatus(rootIndex) { + try { + const result = await getGitStatus(rootIndex); + const statusMap = new Map(); + const ignoredSet = new Set(); + for (const f of (result.files || [])) statusMap.set(f.path, f.status); + for (const f of (result.ignored || [])) ignoredSet.add(f.path); + gitStatusMaps.set(rootIndex, statusMap); + gitIgnoredSets.set(rootIndex, ignoredSet); + } catch { /* git not available */ } + } + + function getGitStatusForPath(rootIndex, path) { + const m = gitStatusMaps.get(rootIndex); + if (m && m.has(path)) return m.get(path); + // Check if this path or any parent is ignored + const s = gitIgnoredSets.get(rootIndex); + if (s) { + if (s.has(path)) return 'ignored'; + // Check parent paths (e.g. node_modules is ignored → node_modules/foo is too) + const parts = path.split('/'); + for (let i = 1; i < parts.length; i++) { + if (s.has(parts.slice(0, i).join('/'))) return 'ignored'; + } + } + return null; + } + + function getFolderGitStatus(rootIndex, folderPath) { + // Check if the folder itself is ignored + const s = gitIgnoredSets.get(rootIndex); + if (s) { + if (s.has(folderPath)) return 'ignored'; + const parts = folderPath.split('/'); + for (let i = 1; i < parts.length; i++) { + if (s.has(parts.slice(0, i).join('/'))) return 'ignored'; + } + } + const m = gitStatusMaps.get(rootIndex); + if (!m) return null; + const prefix = folderPath ? folderPath + '/' : ''; + let dominated = null; + // VS Code priority: conflict > modified > deleted > added > untracked > renamed + const priority = { conflict: 6, modified: 5, deleted: 4, added: 3, untracked: 2, renamed: 1 }; + for (const [path, status] of m) { + if (path.startsWith(prefix)) { + const p = priority[status] || 0; + if (!dominated || p > (priority[dominated] || 0)) dominated = status; + } + } + return dominated; + } + + function gitBadgeLetter(status) { + const m = { modified:'M', added:'A', deleted:'D', untracked:'U', renamed:'R', conflict:'!', ignored:'I' }; + return m[status] || ''; + } + function gitLabelClass(status) { return status ? 'git-' + status : ''; } + function gitBadgeClass(status) { + const m = { modified:'badge-M', added:'badge-A', deleted:'badge-D', untracked:'badge-U', renamed:'badge-R', conflict:'badge-C', ignored:'badge-I' }; + return m[status] || ''; + } + + // ── File Tree ────────────────────────────────────────────────────── + const treeEl = document.getElementById('file-tree'); + const expandedDirs = new Set(); // "rootIndex:path" keys + const expandedRoots = new Set(); // rootIndex values + + function dirKey(rootIndex, path) { return rootIndex + ':' + path; } + + async function renderTree(parentEl, rootIndex, path, depth) { + let entries; + try { entries = await readDir(rootIndex, path); } catch { return; } + entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + for (const entry of entries) { + const fullPath = path ? path + '/' + entry.name : entry.name; + const dk = dirKey(rootIndex, fullPath); + const isExpanded = expandedDirs.has(dk); + const gitStatus = entry.isDirectory + ? getFolderGitStatus(rootIndex, fullPath) + : getGitStatusForPath(rootIndex, fullPath); + + const row = document.createElement('div'); + row.className = 'tree-row'; + row.dataset.path = dk; + row.dataset.rootIndex = rootIndex; + row.dataset.relPath = fullPath; + row.dataset.isDir = entry.isDirectory ? '1' : '0'; + if (dk === selectedTreePath) row.classList.add('selected'); + + const indent = document.createElement('span'); + indent.className = 'tree-indent'; + for (let i = 0; i < depth; i++) { + const guide = document.createElement('span'); + guide.className = 'indent-guide active'; + indent.appendChild(guide); + } + row.appendChild(indent); + + const twistie = document.createElement('span'); + twistie.className = 'tree-twistie'; + if (entry.isDirectory) { + twistie.textContent = '\uEAB6'; + if (isExpanded) twistie.classList.add('expanded'); + } else { + twistie.classList.add('hidden'); + } + row.appendChild(twistie); + + const icon = document.createElement('span'); + icon.className = 'tree-icon ' + fileIconClass(entry.name, entry.isDirectory, isExpanded); + icon.textContent = fileCodiconChar(entry.name, entry.isDirectory, isExpanded); + row.appendChild(icon); + + const label = document.createElement('span'); + label.className = 'tree-label'; + if (gitStatus) label.classList.add(gitLabelClass(gitStatus)); + label.textContent = entry.name; + row.appendChild(label); + + if (gitStatus && gitStatus !== 'ignored') { + if (entry.isDirectory) { + const dot = document.createElement('span'); + dot.className = 'tree-badge badge-dot'; + const colorVar = gitStatus === 'modified' ? '--git-modified' : + gitStatus === 'added' ? '--git-added' : + gitStatus === 'untracked' ? '--git-untracked' : + gitStatus === 'deleted' ? '--git-deleted' : + gitStatus === 'conflict' ? '--git-conflict' : '--git-modified'; + dot.style.background = `var(${colorVar})`; + row.appendChild(dot); + } else { + const badge = document.createElement('span'); + badge.className = 'tree-badge ' + gitBadgeClass(gitStatus); + badge.textContent = gitBadgeLetter(gitStatus); + row.appendChild(badge); + } + } + + parentEl.appendChild(row); + + if (entry.isDirectory) { + const children = document.createElement('div'); + children.className = 'tree-children'; + if (isExpanded) { + children.classList.add('expanded'); + await renderTree(children, rootIndex, fullPath, depth + 1); + } + parentEl.appendChild(children); + + row.addEventListener('click', async (e) => { + e.stopPropagation(); + handleSelection(dk, e); + if (expandedDirs.has(dk)) { + expandedDirs.delete(dk); + twistie.classList.remove('expanded'); + children.classList.remove('expanded'); + children.innerHTML = ''; + icon.className = 'tree-icon ' + fileIconClass(entry.name, true, false); + icon.textContent = fileCodiconChar(entry.name, true, false); + } else { + expandedDirs.add(dk); + twistie.classList.add('expanded'); + children.innerHTML = ''; + await renderTree(children, rootIndex, fullPath, depth + 1); + children.classList.add('expanded'); + icon.className = 'tree-icon ' + fileIconClass(entry.name, true, true); + icon.textContent = fileCodiconChar(entry.name, true, true); + } + }); + } else { + row.addEventListener('click', (e) => { + e.stopPropagation(); + handleSelection(dk, e); + if (!e.shiftKey && !e.metaKey && !e.ctrlKey) { + openFileExternal(rootIndex, fullPath); + } + }); + } + + row.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!selectedTreePaths.has(dk)) selectSingle(dk); + showContextMenu(e.clientX, e.clientY, rootIndex, fullPath, entry.isDirectory, entry.name); + }); + + row.addEventListener('dblclick', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (entry.isDirectory) return; + pinFileExternal(rootIndex, fullPath); + }); + } + } + + // ── Root Folder Rendering ────────────────────────────────────────── + async function renderAllRoots() { + treeEl.innerHTML = ''; + if (roots.length === 1) { + // Single root — no wrapper, render tree directly + expandedRoots.add(roots[0].rootIndex); + await renderTree(treeEl, roots[0].rootIndex, '', 0); + } else { + // Multi-root — each root gets a collapsible header + for (const root of roots) { + const isExpanded = expandedRoots.has(root.rootIndex); + const section = document.createElement('div'); + section.className = 'root-section'; + + const header = document.createElement('div'); + header.className = 'tree-row root-header'; + header.dataset.rootIndex = root.rootIndex; + + const twistie = document.createElement('span'); + twistie.className = 'tree-twistie'; + twistie.textContent = '\uEAB6'; + if (isExpanded) twistie.classList.add('expanded'); + header.appendChild(twistie); + + const icon = document.createElement('span'); + icon.className = 'tree-icon icon-folder' + (isExpanded ? '-open' : ''); + icon.textContent = fileCodiconChar(root.name, true, isExpanded); + header.appendChild(icon); + + const label = document.createElement('span'); + label.className = 'tree-label root-label'; + label.textContent = root.name; + header.appendChild(label); + + section.appendChild(header); + + const children = document.createElement('div'); + children.className = 'tree-children'; + if (isExpanded) { + children.classList.add('expanded'); + await renderTree(children, root.rootIndex, '', 1); + } + section.appendChild(children); + + header.addEventListener('click', async () => { + if (expandedRoots.has(root.rootIndex)) { + expandedRoots.delete(root.rootIndex); + twistie.classList.remove('expanded'); + children.classList.remove('expanded'); + children.innerHTML = ''; + icon.className = 'tree-icon icon-folder'; + icon.textContent = fileCodiconChar(root.name, true, false); + } else { + expandedRoots.add(root.rootIndex); + twistie.classList.add('expanded'); + children.innerHTML = ''; + await renderTree(children, root.rootIndex, '', 1); + children.classList.add('expanded'); + icon.className = 'tree-icon icon-folder-open'; + icon.textContent = fileCodiconChar(root.name, true, true); + } + }); + + treeEl.appendChild(section); + } + } + } + + function highlightSelected() { + treeEl.querySelectorAll('.tree-row').forEach(r => { + const key = r.dataset.path || ''; + r.classList.toggle('selected', selectedTreePaths.has(key)); + r.classList.toggle('focused', key === selectedTreePath); + }); + } + + function getVisiblePaths() { + return Array.from(treeEl.querySelectorAll('.tree-row[data-path]')).map(r => r.dataset.path); + } + + function selectSingle(key) { + selectedTreePaths.clear(); + selectedTreePaths.add(key); + selectedTreePath = key; + lastClickedPath = key; + highlightSelected(); + } + + function selectToggle(key) { + if (selectedTreePaths.has(key)) selectedTreePaths.delete(key); + else selectedTreePaths.add(key); + selectedTreePath = key; + lastClickedPath = key; + highlightSelected(); + } + + function selectRange(toKey) { + const paths = getVisiblePaths(); + const fromIdx = paths.indexOf(lastClickedPath); + const toIdx = paths.indexOf(toKey); + if (fromIdx === -1 || toIdx === -1) { selectSingle(toKey); return; } + for (let i = Math.min(fromIdx, toIdx); i <= Math.max(fromIdx, toIdx); i++) { + selectedTreePaths.add(paths[i]); + } + selectedTreePath = toKey; + highlightSelected(); + } + + function handleSelection(key, e) { + if (e.shiftKey && lastClickedPath) selectRange(key); + else if (e.metaKey || e.ctrlKey) selectToggle(key); + else selectSingle(key); + } + + // ── Context Menu ─────────────────────────────────────────────────── + let ctxMenu = null; + function removeCtxMenu() { if (ctxMenu) { ctxMenu.remove(); ctxMenu = null; } } + document.addEventListener('click', removeCtxMenu); + + function showContextMenu(x, y, rootIndex, targetPath, isDir, name) { + removeCtxMenu(); + const menu = document.createElement('div'); + menu.className = 'context-menu'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + + const parentDir = isDir ? targetPath : targetPath.substring(0, targetPath.lastIndexOf('/')) || ''; + const items = [ + { label: 'New File...', action: () => promptNewFile(rootIndex, parentDir) }, + { label: 'New Folder...', action: () => promptNewFolder(rootIndex, parentDir) }, + { separator: true }, + { label: 'Rename', shortcut: 'F2', action: () => { + const dk = dirKey(rootIndex, targetPath); + const row = treeEl.querySelector(`[data-path="${CSS.escape(dk)}"]`); + if (row) startInlineRename(row, rootIndex, targetPath, name); + }}, + { label: 'Delete', action: () => confirmDelete(rootIndex, targetPath, name, isDir) }, + { separator: true }, + { label: 'Copy Path', action: () => navigator.clipboard.writeText(targetPath).catch(() => {}) }, + ]; + + for (const item of items) { + if (item.separator) { + const sep = document.createElement('div'); + sep.className = 'context-menu-separator'; + menu.appendChild(sep); + continue; + } + const el = document.createElement('div'); + el.className = 'context-menu-item'; + el.textContent = item.label; + if (item.shortcut) { + const sc = document.createElement('span'); + sc.className = 'shortcut'; + sc.textContent = item.shortcut; + el.appendChild(sc); + } + el.addEventListener('click', (e) => { e.stopPropagation(); removeCtxMenu(); item.action(); }); + menu.appendChild(el); + } + + document.body.appendChild(menu); + ctxMenu = menu; + const r = menu.getBoundingClientRect(); + if (r.right > window.innerWidth) menu.style.left = (window.innerWidth - r.width - 4) + 'px'; + if (r.bottom > window.innerHeight) menu.style.top = (window.innerHeight - r.height - 4) + 'px'; + } + + async function confirmDelete(rootIndex, path, name, isDir) { + if (!confirm(`Delete "${name}"?`)) return; + try { + await deleteFile(rootIndex, path); + await renderAllRoots(); + } catch (err) { console.error('Delete failed:', err); } + } + + // ── Inline Rename ────────────────────────────────────────────────── + function startInlineRename(row, rootIndex, path, name) { + const label = row.querySelector('.tree-label'); + if (!label) return; + inlineInputActive = true; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'tree-inline-input'; + input.value = name; + const dotIdx = name.lastIndexOf('.'); + label.style.display = 'none'; + row.appendChild(input); + input.focus(); + if (dotIdx > 0) input.setSelectionRange(0, dotIdx); + else input.select(); + + function commit() { + inlineInputActive = false; + const newName = input.value.trim(); + input.remove(); + label.style.display = ''; + if (newName && newName !== name && !newName.includes('/')) { + const parentDir = path.substring(0, path.lastIndexOf('/')); + const newPath = parentDir ? parentDir + '/' + newName : newName; + renameFile(rootIndex, path, newPath).then(async () => { + await renderAllRoots(); + }).catch(err => console.error('Rename failed:', err)); + } + } + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { inlineInputActive = false; input.remove(); label.style.display = ''; } + }); + } + + // ── New File / Folder ────────────────────────────────────────────── + function promptNewFile(rootIndex, parentDir) { promptInlineCreate(rootIndex, parentDir, false); } + function promptNewFolder(rootIndex, parentDir) { promptInlineCreate(rootIndex, parentDir, true); } + + function promptInlineCreate(rootIndex, parentDir, isDir) { + const dk = parentDir ? dirKey(rootIndex, parentDir) : null; + if (parentDir && !expandedDirs.has(dk)) { + expandedDirs.add(dk); + renderAllRoots().then(() => promptInlineCreate(rootIndex, parentDir, isDir)); + return; + } + inlineInputActive = true; + let container = treeEl; + if (dk) { + const parentRow = treeEl.querySelector(`[data-path="${CSS.escape(dk)}"]`); + if (parentRow) { + const children = parentRow.nextElementSibling; + if (children && children.classList.contains('tree-children')) container = children; + } + } + const inputRow = document.createElement('div'); + inputRow.className = 'tree-row tree-input-row'; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'tree-inline-input'; + input.placeholder = isDir ? 'Folder name' : 'File name'; + inputRow.appendChild(input); + container.insertBefore(inputRow, container.firstChild); + input.focus(); + + function commit() { + inlineInputActive = false; + const name = input.value.trim(); + inputRow.remove(); + if (!name || name.includes('/')) return; + const fullPath = parentDir ? parentDir + '/' + name : name; + const op = isDir ? createDir(rootIndex, fullPath) : createFile(rootIndex, fullPath); + op.then(async () => { + await renderAllRoots(); + if (!isDir) openFileExternal(rootIndex, fullPath); + }).catch(err => console.error('Create failed:', err)); + } + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { inlineInputActive = false; inputRow.remove(); } + }); + } + + // ── Header ───────────────────────────────────────────────────────── + function setupHeader() { + const header = document.getElementById('sidebar-header'); + const btnGroup = document.createElement('div'); + btnGroup.className = 'header-buttons'; + + const refreshBtn = document.createElement('button'); + refreshBtn.className = 'header-btn'; + refreshBtn.title = 'Refresh'; + refreshBtn.textContent = '\uEB37'; + refreshBtn.addEventListener('click', () => fullRefresh()); + + btnGroup.appendChild(refreshBtn); + header.appendChild(btnGroup); + } + + async function fullRefresh() { + for (const root of roots) { + await refreshGitStatus(root.rootIndex); + } + await renderAllRoots(); + document.getElementById('project-name').textContent = 'EXPLORER'; + } + + // Lightweight refresh: update git status badges in-place without re-rendering the tree. + // Only does a full re-render if the directory structure actually changed. + async function diffRefresh() { + // Snapshot current expanded dirs' entry names before refresh + const oldSnapshots = new Map(); + for (const root of roots) { + if (!expandedRoots.has(root.rootIndex)) continue; + oldSnapshots.set(root.rootIndex, await quickSnapshot(root.rootIndex)); + await refreshGitStatus(root.rootIndex); + } + + // Check if any directory structure changed + let structureChanged = false; + for (const [rootIndex, oldSnap] of oldSnapshots) { + const newSnap = await quickSnapshot(rootIndex); + if (newSnap !== oldSnap) { structureChanged = true; break; } + } + + if (structureChanged) { + await renderAllRoots(); + } else { + // Just update git badges/labels in-place + treeEl.querySelectorAll('.tree-row[data-root-index]').forEach(row => { + const ri = parseInt(row.dataset.rootIndex); + const relPath = row.dataset.relPath; + if (relPath === undefined) return; + const isDir = row.dataset.isDir === '1'; + const gitStatus = isDir + ? getFolderGitStatus(ri, relPath) + : getGitStatusForPath(ri, relPath); + + // Update label class + const label = row.querySelector('.tree-label'); + if (label) { + label.className = 'tree-label'; + if (gitStatus) label.classList.add(gitLabelClass(gitStatus)); + } + + // Update badge + const oldBadge = row.querySelector('.tree-badge'); + if (oldBadge) oldBadge.remove(); + if (gitStatus && gitStatus !== 'ignored') { + if (isDir) { + const dot = document.createElement('span'); + dot.className = 'tree-badge badge-dot'; + const colorVar = gitStatus === 'modified' ? '--git-modified' : + gitStatus === 'added' ? '--git-added' : + gitStatus === 'untracked' ? '--git-untracked' : + gitStatus === 'deleted' ? '--git-deleted' : + gitStatus === 'conflict' ? '--git-conflict' : '--git-modified'; + dot.style.background = `var(${colorVar})`; + row.appendChild(dot); + } else { + const badge = document.createElement('span'); + badge.className = 'tree-badge ' + gitBadgeClass(gitStatus); + badge.textContent = gitBadgeLetter(gitStatus); + row.appendChild(badge); + } + } + }); + } + } + + // Quick snapshot of entry names for a root (only expanded dirs, no content) + async function quickSnapshot(rootIndex) { + return await buildEntryList(rootIndex, ''); + } + + async function buildEntryList(rootIndex, path) { + try { + const entries = await readDir(rootIndex, path); + let s = ''; + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const e of entries) { + const fp = path ? path + '/' + e.name : e.name; + s += fp + (e.isDirectory ? '/' : '') + '\n'; + const dk = dirKey(rootIndex, fp); + if (e.isDirectory && expandedDirs.has(dk)) { + s += await buildEntryList(rootIndex, fp); + } + } + return s; + } catch { return ''; } + } + + // ── Init ─────────────────────────────────────────────────────────── + setupHeader(); + document.getElementById('project-name').textContent = 'EXPLORER'; +})(); diff --git a/Resources/explorer/index.html b/Resources/explorer/index.html new file mode 100644 index 000000000..b5c20fd25 --- /dev/null +++ b/Resources/explorer/index.html @@ -0,0 +1,19 @@ + + + + + + cmux Explorer + + + +
+ +
+
+ + + + diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e17b02090..88bdcbcdc 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2242,6 +2242,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let isRunningUnderXCTest = isRunningUnderXCTest(env) let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch + // Pre-warm editor WebViews with Monaco loaded so file opens are instant + MonacoWebViewPool.shared.warmUp() + DistributedNotificationCenter.default().addObserver( self, selector: #selector(handleThemesReloadNotification(_:)), @@ -9628,6 +9631,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // File search: Cmd+Shift+F + if matchShortcut(event: event, shortcut: StoredShortcut(key: "f", command: true, shift: true, option: false, control: false)) { + NotificationCenter.default.post(name: .cmuxSidebarSwitchToSearch, object: nil) + return true + } + + // Open editor: Cmd+Shift+E + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openEditor)) { + openEditorForCurrentProject() + return true + } + // Open browser: Cmd+Shift+L if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openBrowser)) { _ = openBrowserAndFocusAddressBar(insertAtEnd: true) @@ -9868,6 +9883,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + func openEditorForCurrentProject() { + guard let tabManager = tabManager, + let tabId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + let rootPath = workspace.currentDirectory + _ = tabManager.openEditor(rootPath: rootPath) + } + @discardableResult func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { let preferredProfileID = diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 57706817a..0eb884c1b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1616,6 +1616,18 @@ struct ContentView: View { private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool + @State private var explorerPanel: ExplorerSidebarPanel? + @State private var explorerHeight: CGFloat = 250 + @State private var isExplorerVisible: Bool = true + @StateObject private var nativeExplorerViewModel = NativeFileExplorerViewModel() + @StateObject private var fileSearchViewModel = FileSearchViewModel() + @State private var sidebarTab: SidebarTab = .workspaces + + enum SidebarTab: String, CaseIterable { + case workspaces + case explorer + case search + } private enum CommandPaletteMode { case commands @@ -2317,16 +2329,183 @@ struct ContentView: View { } } + /// Space at top of sidebar for traffic light buttons (matches VerticalTabsSidebar.trafficLightPadding) + private let sidebarTrafficLightPadding: CGFloat = 28 + private var sidebarView: some View { - VerticalTabsSidebar( - updateViewModel: updateViewModel, - onSendFeedback: presentFeedbackComposer, - selection: $sidebarSelectionState.selection, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex - ) + VStack(spacing: 0) { + // Space for traffic lights / fullscreen controls + Spacer().frame(height: sidebarTrafficLightPadding) + + // Tab selector + SidebarTabSelector(selected: $sidebarTab) + + // Content + switch sidebarTab { + case .workspaces: + VerticalTabsSidebar( + updateViewModel: updateViewModel, + onSendFeedback: presentFeedbackComposer, + selection: $sidebarSelectionState.selection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + ) + case .explorer: + NativeFileExplorerView(viewModel: nativeExplorerViewModel) + case .search: + FileSearchView(viewModel: fileSearchViewModel) + } + } .frame(width: sidebarWidth) .frame(maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .top) { + SidebarTopScrim(height: sidebarTrafficLightPadding + 20) + .allowsHitTesting(false) + } + .onReceive(NotificationCenter.default.publisher(for: .cmuxSidebarSwitchToSearch)) { _ in + sidebarTab = .search + if !sidebarState.isVisible { sidebarState.isVisible = true } + } + .onAppear { + setupExplorerPanel() + setupNativeExplorer() + } + .onChange(of: tabManager.selectedTabId) { _, _ in + updateExplorerRootPaths() + updateNativeExplorerRoots() + } + .onChange(of: tabManager.tabs.count) { _, _ in + updateExplorerRootPaths() + updateNativeExplorerRoots() + } + .onReceive(selectedWorkspacePanelDirectoriesPublisher) { _ in + updateExplorerRootPaths() + updateNativeExplorerRoots() + } + } + + private func setupExplorerPanel() { + guard explorerPanel == nil else { return } + let rootPaths = collectCurrentWorkspaceRootPaths() + let panel = ExplorerSidebarPanel(rootPaths: rootPaths) + panel.onOpenFile = { [weak tabManager] filePath in + guard let tabManager else { return } + guard let workspaceId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + + // VS Code preview tab behavior: + // - If there's a preview (unpinned) editor, reuse it + // - If all editors are pinned (edited or double-clicked), create a new one + let previewEditor = workspace.panels.values + .compactMap { $0 as? EditorPanel } + .first(where: { $0.isPreview }) + + if let preview = previewEditor { + preview.openFileByPath(filePath) + workspace.focusPanel(preview.id) + } else { + let rootPath = workspace.currentDirectory + _ = tabManager.openEditor(rootPath: rootPath, filePath: filePath, focus: true) + } + } + panel.onPinFile = { [weak tabManager] filePath in + guard let tabManager else { return } + guard let workspaceId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + + // Find the preview editor showing this file and pin it + if let previewEditor = workspace.panels.values + .compactMap({ $0 as? EditorPanel }) + .first(where: { $0.isPreview }) { + previewEditor.isPreview = false + workspace.focusPanel(previewEditor.id) + } + } + + explorerPanel = panel + } + + /// Publisher that fires when the selected workspace's panelDirectories change. + /// Debounced to avoid constant re-renders from shell integration cwd updates. + private var selectedWorkspacePanelDirectoriesPublisher: AnyPublisher<[UUID: String], Never> { + guard let workspace = tabManager.tabs.first(where: { $0.id == tabManager.selectedTabId }) else { + return Empty().eraseToAnyPublisher() + } + return workspace.$panelDirectories + .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .removeDuplicates() + .eraseToAnyPublisher() + } + + private func updateExplorerRootPaths() { + let paths = collectCurrentWorkspaceRootPaths() + explorerPanel?.updateRootPaths(paths) + } + + /// Collect unique directories from the current workspace's panels. + private func collectCurrentWorkspaceRootPaths() -> [String] { + guard let workspace = tabManager.tabs.first(where: { $0.id == tabManager.selectedTabId }) else { + return [FileManager.default.homeDirectoryForCurrentUser.path] + } + + var seen = Set() + var paths: [String] = [] + + // Always include the workspace's own current directory first + let wsDir = workspace.currentDirectory + if seen.insert(wsDir).inserted { + paths.append(wsDir) + } + + // Add unique directories from all panels in this workspace + for dir in workspace.panelDirectories.values { + let trimmed = dir.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if seen.insert(trimmed).inserted { + paths.append(trimmed) + } + } + + return paths + } + + private func setupNativeExplorer() { + let paths = collectCurrentWorkspaceRootPaths() + nativeExplorerViewModel.updateRootPaths(paths) + fileSearchViewModel.rootPaths = paths + + nativeExplorerViewModel.onOpenFile = { [weak tabManager] filePath in + guard let tabManager else { return } + guard let workspaceId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + let previewEditor = workspace.panels.values + .compactMap { $0 as? EditorPanel } + .first(where: { $0.isPreview }) + if let preview = previewEditor { + preview.openFileByPath(filePath) + workspace.focusPanel(preview.id) + } else { + _ = tabManager.openEditor(rootPath: workspace.currentDirectory, filePath: filePath, focus: true) + } + } + nativeExplorerViewModel.onPinFile = { [weak tabManager] filePath in + guard let tabManager else { return } + guard let workspaceId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + if let previewEditor = workspace.panels.values + .compactMap({ $0 as? EditorPanel }) + .first(where: { $0.isPreview }) { + previewEditor.isPreview = false + workspace.focusPanel(previewEditor.id) + } + } + fileSearchViewModel.onOpenFile = nativeExplorerViewModel.onOpenFile + } + + private func updateNativeExplorerRoots() { + let paths = collectCurrentWorkspaceRootPaths() + nativeExplorerViewModel.updateRootPaths(paths) + fileSearchViewModel.rootPaths = paths } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -4994,6 +5173,8 @@ struct ContentView: View { return String(localized: "commandPalette.kind.browser", defaultValue: "Browser") case .markdown: return String(localized: "commandPalette.kind.markdown", defaultValue: "Markdown") + case .editor: + return String(localized: "commandPalette.kind.editor", defaultValue: "Editor") } } @@ -5005,6 +5186,8 @@ struct ContentView: View { return ["browser", "web", "page"] case .markdown: return ["markdown", "note", "preview"] + case .editor: + return ["editor", "code", "file", "monaco"] } } @@ -8476,10 +8659,6 @@ struct VerticalTabsSidebar: View { GeometryReader { proxy in ScrollView { VStack(spacing: 0) { - // Space for traffic lights / fullscreen controls - Spacer() - .frame(height: trafficLightPadding) - LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in let selectedContextIds: Set = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id] @@ -8548,10 +8727,6 @@ struct VerticalTabsSidebar: View { } .frame(width: 0, height: 0) ) - .overlay(alignment: .top) { - SidebarTopScrim(height: trafficLightPadding + 20) - .allowsHitTesting(false) - } .overlay(alignment: .top) { // Match native titlebar behavior in the sidebar top strip: // drag-to-move and double-click action (zoom/minimize). diff --git a/Sources/FileSearchView.swift b/Sources/FileSearchView.swift new file mode 100644 index 000000000..b8560267b --- /dev/null +++ b/Sources/FileSearchView.swift @@ -0,0 +1,258 @@ +import SwiftUI +import AppKit + +/// View model for file search across workspace directories. +@MainActor +final class FileSearchViewModel: ObservableObject { + @Published var query: String = "" + @Published var results: [FileSearchResult] = [] + @Published var isSearching: Bool = false + var rootPaths: [String] = [] + var onOpenFile: ((String) -> Void)? + + private var searchTask: Task? + + func search() { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + results = [] + isSearching = false + return + } + searchTask?.cancel() + isSearching = true + let paths = rootPaths + let searchQuery = query.lowercased() + + searchTask = Task { + var found: [FileSearchResult] = [] + for rootPath in paths { + let rootName = (rootPath as NSString).lastPathComponent + await searchDirectory( + at: rootPath, + relativeTo: "", + rootPath: rootPath, + rootName: rootName, + query: searchQuery, + results: &found, + maxResults: 200 + ) + if found.count >= 200 { break } + } + if !Task.isCancelled { + results = found + isSearching = false + } + } + } + + private func searchDirectory( + at path: String, + relativeTo parent: String, + rootPath: String, + rootName: String, + query: String, + results: inout [FileSearchResult], + maxResults: Int + ) async { + guard results.count < maxResults else { return } + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: path) else { return } + + for entry in entries.sorted() { + if Task.isCancelled || results.count >= maxResults { return } + if entry == ".git" { continue } + + let relativePath = parent.isEmpty ? entry : parent + "/" + entry + let fullPath = (path as NSString).appendingPathComponent(entry) + var isDir: ObjCBool = false + fm.fileExists(atPath: fullPath, isDirectory: &isDir) + + if entry.lowercased().contains(query) { + results.append(FileSearchResult( + name: entry, + relativePath: relativePath, + absolutePath: fullPath, + rootName: rootName, + isDirectory: isDir.boolValue + )) + } + + if isDir.boolValue { + // Skip heavy directories + let skip: Set = ["node_modules", ".build", "DerivedData", "__pycache__", "dist", ".next", "Pods", "target", "zig-out", ".zig-cache"] + if !skip.contains(entry) { + await searchDirectory( + at: fullPath, + relativeTo: relativePath, + rootPath: rootPath, + rootName: rootName, + query: query, + results: &results, + maxResults: maxResults + ) + } + } + } + } +} + +struct FileSearchResult: Identifiable { + let id = UUID() + let name: String + let relativePath: String + let absolutePath: String + let rootName: String + let isDirectory: Bool +} + +/// NSTextField wrapper that properly claims and holds first responder from terminals. +struct SidebarSearchField: NSViewRepresentable { + @Binding var text: String + var placeholder: String + var onChanged: () -> Void + var shouldFocus: Bool = false + + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField() + field.placeholderString = placeholder + field.isBordered = false + field.drawsBackground = false + field.font = .systemFont(ofSize: 13) + field.focusRingType = .none + field.delegate = context.coordinator + field.cell?.lineBreakMode = .byTruncatingTail + return field + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + if nsView.stringValue != text { + nsView.stringValue = text + } + if shouldFocus, !context.coordinator.hasFocused, let window = nsView.window { + context.coordinator.hasFocused = true + window.makeFirstResponder(nsView) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSTextFieldDelegate { + var parent: SidebarSearchField + var hasFocused = false + init(_ parent: SidebarSearchField) { self.parent = parent } + + func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSTextField else { return } + parent.text = field.stringValue + parent.onChanged() + } + } +} + +struct FileSearchView: View { + @ObservedObject var viewModel: FileSearchViewModel + @State private var shouldFocusSearch = false + + var body: some View { + VStack(spacing: 0) { + // Search field + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .font(.system(size: 12)) + + SidebarSearchField( + text: $viewModel.query, + placeholder: String(localized: "fileSearch.placeholder", defaultValue: "Search files..."), + onChanged: { viewModel.search() }, + shouldFocus: shouldFocusSearch + ) + .frame(height: 20) + + if !viewModel.query.isEmpty { + Button { + viewModel.query = "" + viewModel.results = [] + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .font(.system(size: 12)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.05)) + .cornerRadius(6) + .padding(.horizontal, 8) + .padding(.vertical, 8) + + Divider() + + if viewModel.isSearching { + ProgressView() + .scaleEffect(0.6) + .padding(.top, 20) + Spacer() + } else if viewModel.results.isEmpty && !viewModel.query.isEmpty { + Text(String(localized: "fileSearch.noResults", defaultValue: "No files found")) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .padding(.top, 20) + Spacer() + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(viewModel.results) { result in + FileSearchResultRow(result: result) { + viewModel.onOpenFile?(result.absolutePath) + } + } + } + .padding(.vertical, 4) + } + } + } + .onAppear { + // Delay focus to next runloop so the view is mounted + DispatchQueue.main.async { shouldFocusSearch = true } + } + } +} + +struct FileSearchResultRow: View { + let result: FileSearchResult + let onTap: () -> Void + @State private var isHovered = false + + var body: some View { + HStack(spacing: 6) { + FileIconView(name: result.name, isDirectory: result.isDirectory, isExpanded: false) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 1) { + Text(result.name) + .font(.system(size: 13)) + .lineLimit(1) + + Text(result.relativePath) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isHovered ? Color.white.opacity(0.05) : Color.clear) + .contentShape(Rectangle()) + .onHover { isHovered = $0 } + .onTapGesture { onTap() } + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 657dd1887..210f9591e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8447,6 +8447,16 @@ final class GhosttySurfaceScrollView: NSView { #endif return } + // Don't steal focus from sidebar text fields (file search, etc.) + if let fr = window.firstResponder as? NSText, fr.delegate is NSTextField { + // An NSTextField is being edited — check if it's outside the terminal content area + if let textField = fr.delegate as? NSView, !textField.isDescendant(of: self) { +#if DEBUG + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=sidebarTextFieldFocused") +#endif + return + } + } #if DEBUG dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))") #endif diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index b044c5c76..c0ca38e57 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -43,6 +43,7 @@ enum KeyboardShortcutSettings { case splitBrowserDown // Panels + case openEditor case openBrowser case toggleBrowserDeveloperTools case showBrowserJavaScriptConsole @@ -80,6 +81,7 @@ enum KeyboardShortcutSettings { case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom") case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right") case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down") + case .openEditor: return String(localized: "shortcut.openEditor.label", defaultValue: "Open Editor") case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools") case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console") @@ -117,6 +119,7 @@ enum KeyboardShortcutSettings { case .selectSurfaceByNumber: return "shortcut.selectSurfaceByNumber" case .newSurface: return "shortcut.newSurface" case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode" + case .openEditor: return "shortcut.openEditor" case .openBrowser: return "shortcut.openBrowser" case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" @@ -181,6 +184,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) case .toggleTerminalCopyMode: return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false) + case .openEditor: + return StoredShortcut(key: "e", command: true, shift: true, option: false, control: false) case .selectWorkspaceByNumber: return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) case .openBrowser: @@ -320,6 +325,7 @@ enum KeyboardShortcutSettings { static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) } static func selectWorkspaceByNumberShortcut() -> StoredShortcut { shortcut(for: .selectWorkspaceByNumber) } + static func openEditorShortcut() -> StoredShortcut { shortcut(for: .openEditor) } static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) } static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) } static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } diff --git a/Sources/MonacoWebViewPool.swift b/Sources/MonacoWebViewPool.swift new file mode 100644 index 000000000..f5cc63126 --- /dev/null +++ b/Sources/MonacoWebViewPool.swift @@ -0,0 +1,126 @@ +import Foundation +import WebKit + +/// Pre-warms WKWebViews with Monaco Editor loaded, ready for instant use. +/// Call `MonacoWebViewPool.shared.warmUp()` at app startup. +/// When creating an EditorPanel, call `take()` to get a ready WebView. +@MainActor +final class MonacoWebViewPool { + static let shared = MonacoWebViewPool() + + private var available: [(webView: WKWebView, handler: EditorMessageHandler)] = [] + private var warming: Int = 0 + private let poolSize = 2 + + private init() {} + + /// Start pre-warming WebViews. Call once at app startup. + func warmUp() { + refill() + } + + /// Take a pre-warmed WebView + handler pair. Returns nil if none ready (caller should create fresh). + /// After taking, the pool refills in the background. + func take() -> (webView: WKWebView, handler: EditorMessageHandler)? { + guard !available.isEmpty else { return nil } + let item = available.removeFirst() + refill() + return item + } + + /// How many are ready right now. + var readyCount: Int { available.count } + + private func refill() { + let needed = poolSize - available.count - warming + for _ in 0.. Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + onFinish() + } +} diff --git a/Sources/NativeFileExplorer.swift b/Sources/NativeFileExplorer.swift new file mode 100644 index 000000000..aa0dd59d1 --- /dev/null +++ b/Sources/NativeFileExplorer.swift @@ -0,0 +1,584 @@ +import SwiftUI +import AppKit + +// MARK: - Data Model + +/// Represents a file/directory entry in the explorer tree. +@MainActor +final class FileNode: ObservableObject, Identifiable { + let id = UUID() + let name: String + let relativePath: String + let rootPath: String + let isDirectory: Bool + @Published var children: [FileNode]? + @Published var isExpanded: Bool = false + @Published var gitStatus: String? + @Published var isIgnored: Bool = false + + var absolutePath: String { + rootPath.isEmpty ? relativePath : (rootPath as NSString).appendingPathComponent(relativePath) + } + + init(name: String, relativePath: String, rootPath: String, isDirectory: Bool) { + self.name = name + self.relativePath = relativePath + self.rootPath = rootPath + self.isDirectory = isDirectory + } +} + +/// Manages the file tree for a single root directory. +@MainActor +final class FileTreeRoot: ObservableObject, Identifiable { + let id = UUID() + let path: String + var name: String { (path as NSString).lastPathComponent } + @Published var children: [FileNode] = [] + @Published var isExpanded: Bool = true + @Published var gitStatusMap: [String: String] = [:] + @Published var gitIgnoredPaths: Set = [] + + private var fsEventStream: FSEventStreamRef? + private var debounceWorkItem: DispatchWorkItem? + + var onChanged: (() -> Void)? + + init(path: String) { + self.path = path + loadChildren() + loadGitStatus() + startFSEvents() + } + + deinit { + if let stream = fsEventStream { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + } + + func loadChildren() { + let rootPath = path + let statusMap = gitStatusMap + let ignored = gitIgnoredPaths + + DispatchQueue.global(qos: .userInitiated).async { + let entries = Self.loadEntries( + at: rootPath, relativeTo: "", rootPath: rootPath, + gitStatusMap: statusMap, gitIgnoredPaths: ignored + ) + DispatchQueue.main.async { [weak self] in + self?.children = entries + } + } + } + + func loadChildrenForNode(_ node: FileNode) { + let dirPath = (node.rootPath as NSString).appendingPathComponent(node.relativePath) + node.children = Self.loadEntries( + at: dirPath, + relativeTo: node.relativePath, + rootPath: node.rootPath, + gitStatusMap: gitStatusMap, + gitIgnoredPaths: gitIgnoredPaths + ) + } + + static func loadEntries( + at directoryPath: String, + relativeTo parentRelative: String, + rootPath: String, + gitStatusMap: [String: String], + gitIgnoredPaths: Set + ) -> [FileNode] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: directoryPath) else { return [] } + + return entries + .filter { $0 != ".git" } + .sorted { lhs, rhs in + let lhsPath = (directoryPath as NSString).appendingPathComponent(lhs) + let rhsPath = (directoryPath as NSString).appendingPathComponent(rhs) + var lhsIsDir: ObjCBool = false + var rhsIsDir: ObjCBool = false + fm.fileExists(atPath: lhsPath, isDirectory: &lhsIsDir) + fm.fileExists(atPath: rhsPath, isDirectory: &rhsIsDir) + if lhsIsDir.boolValue != rhsIsDir.boolValue { return lhsIsDir.boolValue } + return lhs.localizedStandardCompare(rhs) == .orderedAscending + } + .map { entryName in + let entryPath = (directoryPath as NSString).appendingPathComponent(entryName) + var isDir: ObjCBool = false + fm.fileExists(atPath: entryPath, isDirectory: &isDir) + let relativePath = parentRelative.isEmpty ? entryName : parentRelative + "/" + entryName + let node = FileNode(name: entryName, relativePath: relativePath, rootPath: rootPath, isDirectory: isDir.boolValue) + + // Apply git status + if let status = gitStatusMap[relativePath] { + node.gitStatus = status + } + // Check ignored + if gitIgnoredPaths.contains(relativePath) || isParentIgnored(relativePath, in: gitIgnoredPaths) { + node.isIgnored = true + } + // Folder git status: bubble up from children + if isDir.boolValue { + node.gitStatus = folderGitStatus(relativePath, gitStatusMap: gitStatusMap) + if node.isIgnored { + node.gitStatus = "ignored" + } + } + + return node + } + } + + private static func isParentIgnored(_ path: String, in ignoredPaths: Set) -> Bool { + let parts = path.split(separator: "/") + for i in 1.. String? { + let prefix = folderPath + "/" + let priority: [String: Int] = [ + "conflict": 6, "modified": 5, "deleted": 4, + "added": 3, "untracked": 2, "renamed": 1 + ] + var best: String? + var bestPriority = 0 + for (path, status) in gitStatusMap { + if path.hasPrefix(prefix) { + let p = priority[status] ?? 0 + if p > bestPriority { best = status; bestPriority = p } + } + } + return best + } + + func loadGitStatus() { + DispatchQueue.global(qos: .userInitiated).async { [path] in + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", path, "status", "--porcelain=v1", "-unormal", "--ignored"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + guard (try? process.run()) != nil else { return } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(data: data, encoding: .utf8) ?? "" + + var statusMap: [String: String] = [:] + var ignoredPaths: Set = [] + + for line in output.components(separatedBy: "\n") where line.count >= 3 { + let index = String(line[line.index(line.startIndex, offsetBy: 0)]) + let workTree = String(line[line.index(line.startIndex, offsetBy: 1)]) + var filePath = String(line[line.index(line.startIndex, offsetBy: 3)...]) + if filePath.hasSuffix("/") { filePath = String(filePath.dropLast()) } + if filePath.contains(" -> ") { filePath = filePath.components(separatedBy: " -> ").last ?? filePath } + + if index == "!" && workTree == "!" { + ignoredPaths.insert(filePath) + continue + } + + let status: String + if index == "?" && workTree == "?" { status = "untracked" } + else if index == "U" || workTree == "U" || (index == "A" && workTree == "A") || (index == "D" && workTree == "D") { status = "conflict" } + else if index == "A" || workTree == "A" { status = "added" } + else if index == "D" || workTree == "D" { status = "deleted" } + else if index == "R" { status = "renamed" } + else { status = "modified" } + + statusMap[filePath] = status + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.gitStatusMap = statusMap + self.gitIgnoredPaths = ignoredPaths + // Update git status on existing nodes in-place (no tree rebuild) + self.updateGitStatusInPlace(nodes: self.children) + } + } + } + + /// Recursively update git status on existing nodes without rebuilding the tree. + private func updateGitStatusInPlace(nodes: [FileNode]) { + for node in nodes { + let isIgnored = gitIgnoredPaths.contains(node.relativePath) || + Self.isParentIgnored(node.relativePath, in: gitIgnoredPaths) + node.isIgnored = isIgnored + + if node.isDirectory { + if isIgnored { + node.gitStatus = "ignored" + } else { + node.gitStatus = Self.folderGitStatus(node.relativePath, gitStatusMap: gitStatusMap) + } + if let children = node.children { + updateGitStatusInPlace(nodes: children) + } + } else { + node.gitStatus = gitStatusMap[node.relativePath] + } + } + } + + /// Check if directory entries changed (files added/removed) and reload only if needed. + func refreshStructureIfNeeded() { + let fm = FileManager.default + let currentEntries = Set((try? fm.contentsOfDirectory(atPath: path))?.filter { $0 != ".git" } ?? []) + let knownEntries = Set(children.map(\.name)) + if currentEntries != knownEntries { + loadChildren() + } + // Also check expanded subdirectories + for child in children where child.isDirectory && child.isExpanded { + refreshNodeStructureIfNeeded(child) + } + } + + private func refreshNodeStructureIfNeeded(_ node: FileNode) { + let fullPath = (node.rootPath as NSString).appendingPathComponent(node.relativePath) + let fm = FileManager.default + let currentEntries = Set((try? fm.contentsOfDirectory(atPath: fullPath))?.filter { $0 != ".git" } ?? []) + let knownEntries = Set(node.children?.map(\.name) ?? []) + if currentEntries != knownEntries { + loadChildrenForNode(node) + } + for child in (node.children ?? []) where child.isDirectory && child.isExpanded { + refreshNodeStructureIfNeeded(child) + } + } + + // MARK: - FSEvents + + private func startFSEvents() { + let paths = [path] as CFArray + var context = FSEventStreamContext() + context.info = Unmanaged.passUnretained(self).toOpaque() + + let callback: FSEventStreamCallback = { _, info, _, _, _, _ in + guard let info else { return } + let root = Unmanaged.fromOpaque(info).takeUnretainedValue() + DispatchQueue.main.async { + root.debouncedRefresh() + } + } + + guard let stream = FSEventStreamCreate( + nil, callback, &context, paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 1.0, UInt32(kFSEventStreamCreateFlagUseCFTypes) + ) else { return } + + FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + FSEventStreamStart(stream) + fsEventStream = stream + } + + private func debouncedRefresh() { + debounceWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self else { return } + self.refreshStructureIfNeeded() + self.loadGitStatus() + self.onChanged?() + } + debounceWorkItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: item) + } +} + +// MARK: - File Explorer View Model + +@MainActor +final class NativeFileExplorerViewModel: ObservableObject { + @Published var roots: [FileTreeRoot] = [] + var onOpenFile: ((String) -> Void)? + var onPinFile: ((String) -> Void)? + + func updateRootPaths(_ paths: [String]) { + let existingByPath = Dictionary(uniqueKeysWithValues: roots.map { ($0.path, $0) }) + var newRoots: [FileTreeRoot] = [] + for path in paths { + if let existing = existingByPath[path] { + newRoots.append(existing) + } else { + let root = FileTreeRoot(path: path) + newRoots.append(root) + } + } + roots = newRoots + } + + func toggleExpand(_ node: FileNode, in root: FileTreeRoot) { + node.isExpanded.toggle() + if node.isExpanded && node.children == nil { + root.loadChildrenForNode(node) + } + objectWillChange.send() + } + + /// Flatten visible tree into a list for efficient LazyVStack rendering. + /// Each entry is either a root header or a file node. + enum FlatRow: Identifiable { + case rootHeader(FileTreeRoot) + case node(FileNode, FileTreeRoot, Int) + + var id: UUID { + switch self { + case .rootHeader(let root): return root.id + case .node(let node, _, _): return node.id + } + } + } + + func flattenedRows() -> [FlatRow] { + var rows: [FlatRow] = [] + for root in roots { + rows.append(.rootHeader(root)) + if root.isExpanded { + flattenNodes(root.children, root: root, depth: 1, into: &rows) + } + } + return rows + } + + private func flattenNodes(_ nodes: [FileNode], root: FileTreeRoot, depth: Int, into rows: inout [FlatRow]) { + for node in nodes { + rows.append(.node(node, root, depth)) + if node.isDirectory && node.isExpanded, let children = node.children { + flattenNodes(children, root: root, depth: depth + 1, into: &rows) + } + } + } +} + +// MARK: - Views + +struct NativeFileExplorerView: View { + @ObservedObject var viewModel: NativeFileExplorerViewModel + + var body: some View { + let flatRows = viewModel.flattenedRows() + + VStack(spacing: 0) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(flatRows) { row in + switch row { + case .rootHeader(let root): + RootHeaderRow(root: root, viewModel: viewModel) + case .node(let node, let root, let depth): + FileNodeRow(node: node, root: root, depth: depth, viewModel: viewModel) + } + } + } + .padding(.vertical, 4) + } + } + } +} + +struct RootHeaderRow: View { + @ObservedObject var root: FileTreeRoot + let viewModel: NativeFileExplorerViewModel + + var body: some View { + HStack(spacing: 4) { + Image(systemName: root.isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .bold)) + .frame(width: 16) + .foregroundStyle(.secondary) + + Image(systemName: root.isExpanded ? "folder.fill" : "folder") + .font(.system(size: 13)) + .foregroundColor(Color(nsColor: NSColor(red: 0.86, green: 0.71, blue: 0.48, alpha: 1))) + .frame(width: 16) + + Text(root.name) + .font(.system(size: 11, weight: .semibold)) + .textCase(.uppercase) + .tracking(0.3) + .lineLimit(1) + } + .padding(.horizontal, 8) + .frame(height: 22) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + root.isExpanded.toggle() + viewModel.objectWillChange.send() + } + } +} + +struct FileNodeRow: View { + @ObservedObject var node: FileNode + let root: FileTreeRoot + let depth: Int + let viewModel: NativeFileExplorerViewModel + @State private var isHovered = false + + var body: some View { + HStack(spacing: 0) { + // Indent + ForEach(0.. Swift communication. +/// Handles writeFile (save) and state notifications from the Monaco editor. +final class EditorMessageHandler: NSObject, WKScriptMessageHandler { + var rootPath: String = "" + var onDirtyStateChanged: ((Bool) -> Void)? + var onActiveFileChanged: ((String?) -> Void)? + var onEditorReady: (() -> Void)? + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard let body = message.body as? [String: Any], + let action = body["action"] as? String else { return } + + switch action { + case "writeFile": + handleWriteFile(body: body, webView: message.webView) + case "dirtyState": + let dirty = body["isDirty"] as? Bool ?? false + DispatchQueue.main.async { self.onDirtyStateChanged?(dirty) } + case "activeFile": + let fileName = body["fileName"] as? String + DispatchQueue.main.async { self.onActiveFileChanged?(fileName) } + case "editorReady": + DispatchQueue.main.async { self.onEditorReady?() } + default: + break + } + } + + // MARK: - File Operations + + private func handleWriteFile(body: [String: Any], webView: WKWebView?) { + let relativePath = body["path"] as? String ?? "" + let content = body["content"] as? String ?? "" + let requestId = body["requestId"] as? String ?? "" + + DispatchQueue.global(qos: .userInitiated).async { [rootPath] in + let fullPath = self.resolvedPath(relativePath, rootPath: rootPath) + guard let fullPath else { + self.sendError(requestId: requestId, message: "Invalid path", webView: webView) + return + } + + do { + try content.write(toFile: fullPath, atomically: true, encoding: .utf8) + self.sendResponse(requestId: requestId, data: ["success": true], webView: webView) + } catch { + self.sendError(requestId: requestId, message: error.localizedDescription, webView: webView) + } + } + } + + // MARK: - Path Safety + + /// Resolve a relative path within rootPath, preventing directory traversal. + private func resolvedPath(_ relativePath: String, rootPath: String) -> String? { + let candidate: String + if relativePath.isEmpty { + candidate = rootPath + } else { + candidate = (rootPath as NSString).appendingPathComponent(relativePath) + } + + // Canonicalize with symlink resolution to prevent traversal + let canonical = URL(fileURLWithPath: candidate).resolvingSymlinksInPath().path + let canonicalRoot = URL(fileURLWithPath: rootPath).resolvingSymlinksInPath().path + + guard canonical == canonicalRoot || canonical.hasPrefix(canonicalRoot + "/") else { + return nil + } + return canonical + } + + // MARK: - JS Communication + + private func sendResponse(requestId: String, data: Any, webView: WKWebView?) { + guard let webView else { return } + guard let jsonData = try? JSONSerialization.data(withJSONObject: data), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + + let js = "window.cmux.handleResponse(\(Self.jsStringLiteral(requestId)), \(jsonString))" + DispatchQueue.main.async { + webView.evaluateJavaScript(js, completionHandler: nil) + } + } + + private func sendError(requestId: String, message: String, webView: WKWebView?) { + guard let webView else { return } + let js = "window.cmux.handleError(\(Self.jsStringLiteral(requestId)), \(Self.jsStringLiteral(message)))" + DispatchQueue.main.async { + webView.evaluateJavaScript(js, completionHandler: nil) + } + } + + /// Encode a Swift string as a safe JavaScript string literal using JSON serialization. + private static func jsStringLiteral(_ value: String) -> String { + // Wrap in array, serialize, then strip the [ ] brackets + guard let data = try? JSONSerialization.data(withJSONObject: [value]), + let arrayStr = String(data: data, encoding: .utf8), + arrayStr.count > 2 else { + return "\"\"" + } + // "[\"escaped string\"]" → "\"escaped string\"" + let start = arrayStr.index(after: arrayStr.startIndex) + let end = arrayStr.index(before: arrayStr.endIndex) + return String(arrayStr[start.. 1_048_576 { + let sizeMB = String(format: "%.1f", Double(size) / 1_048_576) + displayTitle = fileName + let js = "if (window.cmux && window.cmux.showLargeFile) { window.cmux.showLargeFile('\(jsEscape(fileName))', '\(sizeMB) MB — file is too large to open in the editor'); }" + webView.evaluateJavaScript(js, completionHandler: nil) + return + } + + // Read file synchronously — source files are small, this is <1ms + guard let data = fm.contents(atPath: absolutePath), + let content = String(data: data, encoding: .utf8) else { return } + + if content.count > 5_000_000 { + displayTitle = fileName + let js = "if (window.cmux && window.cmux.showLargeFile) { window.cmux.showLargeFile('\(jsEscape(fileName))', 'file is too large to open in the editor'); }" + webView.evaluateJavaScript(js, completionHandler: nil) + return + } + + // Encode content as JSON string for safe JS injection + guard let jsonData = try? JSONSerialization.data(withJSONObject: [content]), + let jsonArray = String(data: jsonData, encoding: .utf8), + jsonArray.count > 2 else { return } + let jsonString = String(jsonArray[jsonArray.index(after: jsonArray.startIndex).. String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } +} + +// MARK: - Navigation delegate to inject theme on page load + +final class EditorThemeInjector: NSObject, WKNavigationDelegate { + weak var panel: EditorPanel? + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor in + panel?.injectThemeColors() + panel?.injectMonacoPaths() + } + } +} + +// MARK: - NSColor helpers + +extension NSColor { + var perceivedBrightness: CGFloat { + guard let rgb = usingColorSpace(.sRGB) else { return 0.5 } + return rgb.redComponent * 0.299 + rgb.greenComponent * 0.587 + rgb.blueComponent * 0.114 + } + + func adjustBrightness(by amount: CGFloat) -> NSColor { + guard let rgb = usingColorSpace(.sRGB) else { return self } + return NSColor( + red: max(0, min(1, rgb.redComponent + amount)), + green: max(0, min(1, rgb.greenComponent + amount)), + blue: max(0, min(1, rgb.blueComponent + amount)), + alpha: rgb.alphaComponent + ) + } +} diff --git a/Sources/Panels/EditorPanelView.swift b/Sources/Panels/EditorPanelView.swift new file mode 100644 index 000000000..6762be0d9 --- /dev/null +++ b/Sources/Panels/EditorPanelView.swift @@ -0,0 +1,141 @@ +import AppKit +import SwiftUI +import WebKit + +/// SwiftUI view that renders an EditorPanel's Monaco Editor web view. +struct EditorPanelView: View { + @ObservedObject var panel: EditorPanel + let isFocused: Bool + let isVisibleInUI: Bool + let portalPriority: Int + let onRequestPanelFocus: () -> Void + + @State private var focusFlashOpacity: Double = 0.0 + @State private var focusFlashAnimationGeneration: Int = 0 + + var body: some View { + EditorWebViewRepresentable(webView: panel.webView) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) + .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) + .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) + .padding(FocusFlashPattern.ringInset) + .allowsHitTesting(false) + } + .overlay { + if isVisibleInUI { + EditorPointerObserver(onPointerDown: onRequestPanelFocus) + } + } + .onChange(of: panel.focusFlashToken) { _ in + triggerFocusFlashAnimation() + } + } + + // MARK: - Focus Flash + + private func triggerFocusFlashAnimation() { + focusFlashAnimationGeneration &+= 1 + let generation = focusFlashAnimationGeneration + focusFlashOpacity = FocusFlashPattern.values.first ?? 0 + + for segment in FocusFlashPattern.segments { + DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { + guard focusFlashAnimationGeneration == generation else { return } + withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { + focusFlashOpacity = segment.targetOpacity + } + } + } + } + + private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation { + switch curve { + case .easeIn: + return .easeIn(duration: duration) + case .easeOut: + return .easeOut(duration: duration) + } + } +} + +// MARK: - NSViewRepresentable for WKWebView + +struct EditorWebViewRepresentable: NSViewRepresentable { + let webView: WKWebView + + func makeNSView(context: Context) -> WKWebView { + webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + // No dynamic updates needed; the web view manages its own state. + } +} + +// MARK: - Pointer Observer + +private struct EditorPointerObserver: NSViewRepresentable { + let onPointerDown: () -> Void + + func makeNSView(context: Context) -> EditorPanelPointerObserverView { + let view = EditorPanelPointerObserverView() + view.onPointerDown = onPointerDown + return view + } + + func updateNSView(_ nsView: EditorPanelPointerObserverView, context: Context) { + nsView.onPointerDown = onPointerDown + } +} + +final class EditorPanelPointerObserverView: NSView { + var onPointerDown: (() -> Void)? + private var eventMonitor: Any? + + override var mouseDownCanMoveWindow: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + installEventMonitorIfNeeded() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func shouldHandle(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + let window, + event.window === window, + !isHiddenOrHasHiddenAncestor else { return false } + let point = convert(event.locationInWindow, from: nil) + return bounds.contains(point) + } + + func handleEventIfNeeded(_ event: NSEvent) -> NSEvent { + guard shouldHandle(event) else { return event } + DispatchQueue.main.async { [weak self] in + self?.onPointerDown?() + } + return event + } + + private func installEventMonitorIfNeeded() { + guard eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + self?.handleEventIfNeeded(event) ?? event + } + } +} diff --git a/Sources/Panels/ExplorerSidebarPanel.swift b/Sources/Panels/ExplorerSidebarPanel.swift new file mode 100644 index 000000000..a4b120d3d --- /dev/null +++ b/Sources/Panels/ExplorerSidebarPanel.swift @@ -0,0 +1,444 @@ +import Foundation +import AppKit +import WebKit + +/// Message handler for the sidebar file explorer WebView. +/// Self-contained file system handler that responds via `window.cmuxExplorer`. +final class ExplorerMessageHandler: NSObject, WKScriptMessageHandler { + var rootPaths: [String] = [] + var onOpenFile: ((String) -> Void)? + var onPinFile: ((String) -> Void)? + + private func rootPath(for body: [String: Any]) -> String? { + let index = body["rootIndex"] as? Int ?? 0 + guard index >= 0 && index < rootPaths.count else { return nil } + return rootPaths[index] + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard let body = message.body as? [String: Any], + let action = body["action"] as? String else { return } + + switch action { + case "pinFileExternal": + guard let relativePath = body["path"] as? String, + let root = rootPath(for: body) else { return } + let fullPath = (root as NSString).appendingPathComponent(relativePath) + DispatchQueue.main.async { [weak self] in + self?.onPinFile?(fullPath) + } + case "openFileExternal": + guard let relativePath = body["path"] as? String, + let root = rootPath(for: body) else { return } + let fullPath = (root as NSString).appendingPathComponent(relativePath) + DispatchQueue.main.async { [weak self] in + self?.onOpenFile?(fullPath) + } + case "readDir": + handleReadDir(body: body, webView: message.webView) + case "createFile": + handleCreateFile(body: body, webView: message.webView) + case "createDir": + handleCreateDir(body: body, webView: message.webView) + case "deleteFile": + handleDeleteFile(body: body, webView: message.webView) + case "renameFile": + handleRenameFile(body: body, webView: message.webView) + case "gitStatus": + handleGitStatus(body: body, webView: message.webView) + default: + break + } + } + + // MARK: - File Operations + + private func handleReadDir(body: [String: Any], webView: WKWebView?) { + let relativePath = body["path"] as? String ?? "" + let requestId = body["requestId"] as? String ?? "" + guard let rootPath = rootPath(for: body) else { + sendError(requestId: requestId, message: "Invalid root", webView: webView); return + } + + DispatchQueue.global(qos: .userInitiated).async { + let fullPath = self.resolvedPath(relativePath, rootPath: rootPath) + guard let fullPath else { + self.sendError(requestId: requestId, message: "Invalid path", webView: webView) + return + } + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: fullPath) else { + self.sendError(requestId: requestId, message: "Cannot read directory", webView: webView) + return + } + var items: [[String: Any]] = [] + for entry in entries.sorted() { + if entry == ".git" { continue } + let entryPath = (fullPath as NSString).appendingPathComponent(entry) + var isDir: ObjCBool = false + fm.fileExists(atPath: entryPath, isDirectory: &isDir) + items.append(["name": entry, "isDirectory": isDir.boolValue]) + } + self.sendResponse(requestId: requestId, data: items, webView: webView) + } + } + + private func handleCreateFile(body: [String: Any], webView: WKWebView?) { + let relativePath = body["path"] as? String ?? "" + let requestId = body["requestId"] as? String ?? "" + guard let rootPath = rootPath(for: body) else { + sendError(requestId: requestId, message: "Invalid root", webView: webView); return + } + DispatchQueue.global(qos: .userInitiated).async { + guard let fullPath = self.resolvedPath(relativePath, rootPath: rootPath) else { + self.sendError(requestId: requestId, message: "Invalid path", webView: webView); return + } + let fm = FileManager.default + if fm.fileExists(atPath: fullPath) { + self.sendError(requestId: requestId, message: "File already exists", webView: webView); return + } + do { + try fm.createDirectory(atPath: (fullPath as NSString).deletingLastPathComponent, withIntermediateDirectories: true) + guard fm.createFile(atPath: fullPath, contents: nil) else { + self.sendError(requestId: requestId, message: "Failed to create file", webView: webView); return + } + self.sendResponse(requestId: requestId, data: ["success": true], webView: webView) + } catch { self.sendError(requestId: requestId, message: error.localizedDescription, webView: webView) } + } + } + + private func handleCreateDir(body: [String: Any], webView: WKWebView?) { + let relativePath = body["path"] as? String ?? "" + let requestId = body["requestId"] as? String ?? "" + guard let rootPath = rootPath(for: body) else { + sendError(requestId: requestId, message: "Invalid root", webView: webView); return + } + DispatchQueue.global(qos: .userInitiated).async { + guard let fullPath = self.resolvedPath(relativePath, rootPath: rootPath) else { + self.sendError(requestId: requestId, message: "Invalid path", webView: webView); return + } + do { + try FileManager.default.createDirectory(atPath: fullPath, withIntermediateDirectories: true) + self.sendResponse(requestId: requestId, data: ["success": true], webView: webView) + } catch { self.sendError(requestId: requestId, message: error.localizedDescription, webView: webView) } + } + } + + private func handleDeleteFile(body: [String: Any], webView: WKWebView?) { + let relativePath = body["path"] as? String ?? "" + let requestId = body["requestId"] as? String ?? "" + guard !relativePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + sendError(requestId: requestId, message: "Cannot delete root", webView: webView); return + } + guard let rootPath = rootPath(for: body) else { + sendError(requestId: requestId, message: "Invalid root", webView: webView); return + } + DispatchQueue.global(qos: .userInitiated).async { + guard let fullPath = self.resolvedPath(relativePath, rootPath: rootPath), + fullPath != URL(fileURLWithPath: rootPath).resolvingSymlinksInPath().path else { + self.sendError(requestId: requestId, message: "Invalid path", webView: webView); return + } + do { + try FileManager.default.removeItem(atPath: fullPath) + self.sendResponse(requestId: requestId, data: ["success": true], webView: webView) + } catch { self.sendError(requestId: requestId, message: error.localizedDescription, webView: webView) } + } + } + + private func handleRenameFile(body: [String: Any], webView: WKWebView?) { + let oldRelPath = body["oldPath"] as? String ?? "" + let newRelPath = body["newPath"] as? String ?? "" + let requestId = body["requestId"] as? String ?? "" + guard !oldRelPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + sendError(requestId: requestId, message: "Cannot rename root", webView: webView); return + } + guard let rootPath = rootPath(for: body) else { + sendError(requestId: requestId, message: "Invalid root", webView: webView); return + } + DispatchQueue.global(qos: .userInitiated).async { + let canonicalRoot = URL(fileURLWithPath: rootPath).resolvingSymlinksInPath().path + guard let oldFull = self.resolvedPath(oldRelPath, rootPath: rootPath), oldFull != canonicalRoot, + let newFull = self.resolvedPath(newRelPath, rootPath: rootPath) else { + self.sendError(requestId: requestId, message: "Invalid path", webView: webView); return + } + do { + try FileManager.default.createDirectory(atPath: (newFull as NSString).deletingLastPathComponent, withIntermediateDirectories: true) + try FileManager.default.moveItem(atPath: oldFull, toPath: newFull) + self.sendResponse(requestId: requestId, data: ["success": true], webView: webView) + } catch { self.sendError(requestId: requestId, message: error.localizedDescription, webView: webView) } + } + } + + private func handleGitStatus(body: [String: Any], webView: WKWebView?) { + let requestId = body["requestId"] as? String ?? "" + guard let rootPath = rootPath(for: body) else { + sendError(requestId: requestId, message: "Invalid root", webView: webView); return + } + DispatchQueue.global(qos: .userInitiated).async { + // git status --porcelain -unormal: fast, doesn't recurse into untracked dirs + let statusProcess = Process() + let statusPipe = Pipe() + statusProcess.executableURL = URL(fileURLWithPath: "/usr/bin/git") + statusProcess.arguments = ["-C", rootPath, "status", "--porcelain=v1", "-unormal", "--ignored"] + statusProcess.standardOutput = statusPipe + statusProcess.standardError = FileHandle.nullDevice + + do { try statusProcess.run() } + catch { + self.sendError(requestId: requestId, message: "git not available", webView: webView); return + } + + let statusData = statusPipe.fileHandleForReading.readDataToEndOfFile() + statusProcess.waitUntilExit() + let statusOutput = String(data: statusData, encoding: .utf8) ?? "" + + var files: [[String: String]] = [] + var ignored: [[String: String]] = [] + + for line in statusOutput.components(separatedBy: "\n") where line.count >= 3 { + let index = String(line[line.index(line.startIndex, offsetBy: 0)]) + let workTree = String(line[line.index(line.startIndex, offsetBy: 1)]) + var path = String(line[line.index(line.startIndex, offsetBy: 3)...]) + // Strip trailing / from directory entries + if path.hasSuffix("/") { path = String(path.dropLast()) } + if path.contains(" -> ") { path = path.components(separatedBy: " -> ").last ?? path } + + if index == "!" && workTree == "!" { + ignored.append(["path": path, "status": "ignored"]) + continue + } + + let status: String + if index == "?" && workTree == "?" { status = "untracked" } + else if index == "U" || workTree == "U" || (index == "A" && workTree == "A") || (index == "D" && workTree == "D") { status = "conflict" } + else if index == "A" || workTree == "A" { status = "added" } + else if index == "D" || workTree == "D" { status = "deleted" } + else if index == "R" { status = "renamed" } + else { status = "modified" } + + files.append(["path": path, "status": status]) + } + + self.sendResponse(requestId: requestId, data: ["files": files, "ignored": ignored], webView: webView) + } + } + + // MARK: - Path Safety + + private func resolvedPath(_ relativePath: String, rootPath: String) -> String? { + let candidate = relativePath.isEmpty ? rootPath : (rootPath as NSString).appendingPathComponent(relativePath) + let canonical = URL(fileURLWithPath: candidate).resolvingSymlinksInPath().path + let canonicalRoot = URL(fileURLWithPath: rootPath).resolvingSymlinksInPath().path + guard canonical == canonicalRoot || canonical.hasPrefix(canonicalRoot + "/") else { return nil } + return canonical + } + + // MARK: - JS Communication (responses go to window.cmuxExplorer) + + private func sendResponse(requestId: String, data: Any, webView: WKWebView?) { + guard let webView else { return } + guard let jsonData = try? JSONSerialization.data(withJSONObject: data), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + let js = "window.cmuxExplorer.handleResponse(\(Self.jsStringLiteral(requestId)), \(jsonString))" + DispatchQueue.main.async { webView.evaluateJavaScript(js, completionHandler: nil) } + } + + private func sendError(requestId: String, message: String, webView: WKWebView?) { + guard let webView else { return } + let js = "window.cmuxExplorer.handleError(\(Self.jsStringLiteral(requestId)), \(Self.jsStringLiteral(message)))" + DispatchQueue.main.async { webView.evaluateJavaScript(js, completionHandler: nil) } + } + + private static func jsStringLiteral(_ value: String) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: [value]), + let arrayStr = String(data: data, encoding: .utf8), + arrayStr.count > 2 else { return "\"\"" } + let start = arrayStr.index(after: arrayStr.startIndex) + let end = arrayStr.index(before: arrayStr.endIndex) + return String(arrayStr[start.. Void)? { + didSet { messageHandler.onOpenFile = onOpenFile } + } + + var onPinFile: ((String) -> Void)? { + didSet { messageHandler.onPinFile = onPinFile } + } + + init(rootPaths: [String]) { + self.rootPaths = rootPaths + + let config = WKWebViewConfiguration() + config.defaultWebpagePreferences.allowsContentJavaScript = true + + let handler = ExplorerMessageHandler() + handler.rootPaths = rootPaths + config.userContentController.add(handler, name: "cmuxExplorer") + self.messageHandler = handler + + let webView = WKWebView(frame: .zero, configuration: config) + webView.setValue(false, forKey: "drawsBackground") + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + self.webView = webView + + loadExplorerHTML() + startFSEvents() + + themeObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDefaultBackgroundDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.injectThemeColors() } + } + } + + deinit { + if let observer = themeObserver { + NotificationCenter.default.removeObserver(observer) + } + if let stream = fsEventStream { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + } + + /// Update the root paths shown in the explorer. Only reloads JS if paths changed. + func updateRootPaths(_ paths: [String]) { + guard paths != rootPaths else { return } + rootPaths = paths + messageHandler.rootPaths = paths + startFSEvents() + if hasLoaded { + sendRootsToJS() + } + } + + // MARK: - FSEvents File Watching + + private func startFSEvents() { + stopFSEvents() + guard !rootPaths.isEmpty else { return } + + let paths = rootPaths as CFArray + var context = FSEventStreamContext() + // Use Unmanaged to pass self as a pointer + context.info = Unmanaged.passUnretained(self).toOpaque() + + let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, _, _ in + guard let info else { return } + let panel = Unmanaged.fromOpaque(info).takeUnretainedValue() + DispatchQueue.main.async { + panel.handleFSEvent() + } + } + + guard let stream = FSEventStreamCreate( + nil, + callback, + &context, + paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.05, // 50ms latency + UInt32(kFSEventStreamCreateFlagUseCFTypes) + ) else { return } + + FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + FSEventStreamStart(stream) + fsEventStream = stream + } + + private func stopFSEvents() { + guard let stream = fsEventStream else { return } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + fsEventStream = nil + } + + private func handleFSEvent() { + let js = "if (window.cmuxExplorer && window.cmuxExplorer.refresh) { window.cmuxExplorer.refresh(); }" + webView.evaluateJavaScript(js, completionHandler: nil) + } + + private func loadExplorerHTML() { + guard let explorerURL = Bundle.main.url( + forResource: "index", + withExtension: "html", + subdirectory: "explorer" + ) else { return } + let explorerDir = explorerURL.deletingLastPathComponent() + webView.navigationDelegate = themeInjector + webView.loadFileURL(explorerURL, allowingReadAccessTo: explorerDir) + } + + private lazy var themeInjector: ExplorerThemeInjector = { + let injector = ExplorerThemeInjector() + injector.panel = self + return injector + }() + + func injectThemeColors() { + let bgColor = GhosttyBackgroundTheme.currentColor() + let bgHex = bgColor.hexString() + let isDark = bgColor.perceivedBrightness < 0.5 + + let fg = isDark ? "#cccccc" : "#333333" + let borderColor = isDark + ? bgColor.adjustBrightness(by: 0.08).hexString() + : bgColor.adjustBrightness(by: -0.08).hexString() + let hoverBg = isDark + ? bgColor.adjustBrightness(by: 0.06).hexString() + : bgColor.adjustBrightness(by: -0.04).hexString() + let selectedBg = isDark + ? bgColor.adjustBrightness(by: 0.10).hexString() + : bgColor.adjustBrightness(by: -0.08).hexString() + let indentGuide = isDark + ? bgColor.adjustBrightness(by: 0.20).hexString() + : bgColor.adjustBrightness(by: -0.15).hexString() + + let js = "if (window.cmuxExplorer && window.cmuxExplorer.updateTheme) { window.cmuxExplorer.updateTheme('\(bgHex)', '\(fg)', '\(borderColor)', '\(hoverBg)', '\(selectedBg)', '\(indentGuide)'); }" + webView.evaluateJavaScript(js, completionHandler: nil) + } + + /// Send the current root paths to the JS explorer so it can render them. + func sendRootsToJS() { + let rootsJSON = rootPaths.enumerated().map { index, path in + let name = (path as NSString).lastPathComponent + return "{\"name\":\"\(name.replacingOccurrences(of: "\"", with: "\\\""))\",\"rootIndex\":\(index)}" + }.joined(separator: ",") + let js = "if (window.cmuxExplorer && window.cmuxExplorer.setRoots) { window.cmuxExplorer.setRoots([\(rootsJSON)]); }" + webView.evaluateJavaScript(js, completionHandler: nil) + } +} + +final class ExplorerThemeInjector: NSObject, WKNavigationDelegate { + weak var panel: ExplorerSidebarPanel? + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor in + panel?.hasLoaded = true + panel?.injectThemeColors() + panel?.sendRootsToJS() + } + } +} diff --git a/Sources/Panels/ExplorerSidebarView.swift b/Sources/Panels/ExplorerSidebarView.swift new file mode 100644 index 000000000..d3bd41585 --- /dev/null +++ b/Sources/Panels/ExplorerSidebarView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import WebKit + +/// NSViewRepresentable that wraps the explorer sidebar's WKWebView. +struct ExplorerWebViewRepresentable: NSViewRepresentable { + let webView: WKWebView + + func makeNSView(context: Context) -> WKWebView { + webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} +} + +/// SwiftUI view for the file explorer sidebar section. +struct ExplorerSidebarView: View { + @ObservedObject var panel: ExplorerSidebarPanel + + var body: some View { + ExplorerWebViewRepresentable(webView: panel.webView) + } +} diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index fc0554dd9..f5de68731 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -7,6 +7,7 @@ public enum PanelType: String, Codable, Sendable { case terminal case browser case markdown + case editor } public enum TerminalPanelFocusIntent: Equatable { diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 2cfb6bf6e..cfd6f7240 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -55,6 +55,16 @@ struct PanelContentView: View { onRequestPanelFocus: onRequestPanelFocus ) } + case .editor: + if let editorPanel = panel as? EditorPanel { + EditorPanelView( + panel: editorPanel, + isFocused: isFocused, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, + onRequestPanelFocus: onRequestPanelFocus + ) + } } } } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 188833d40..f90f5c5b0 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -240,6 +240,11 @@ struct SessionMarkdownPanelSnapshot: Codable, Sendable { var filePath: String } +struct SessionEditorPanelSnapshot: Codable, Sendable { + var rootPath: String + var filePath: String? +} + struct SessionPanelSnapshot: Codable, Sendable { var id: UUID var type: PanelType @@ -254,6 +259,7 @@ struct SessionPanelSnapshot: Codable, Sendable { var terminal: SessionTerminalPanelSnapshot? var browser: SessionBrowserPanelSnapshot? var markdown: SessionMarkdownPanelSnapshot? + var editor: SessionEditorPanelSnapshot? } enum SessionSplitOrientation: String, Codable, Sendable { diff --git a/Sources/SidebarTabSelector.swift b/Sources/SidebarTabSelector.swift new file mode 100644 index 000000000..343ab2c5f --- /dev/null +++ b/Sources/SidebarTabSelector.swift @@ -0,0 +1,44 @@ +import SwiftUI + +extension Notification.Name { + static let cmuxSidebarSwitchToSearch = Notification.Name("cmuxSidebarSwitchToSearch") +} + +/// Tab selector below the traffic lights — switches between Workspaces, Explorer, and Search. +struct SidebarTabSelector: View { + @Binding var selected: ContentView.SidebarTab + + private let tabs: [(ContentView.SidebarTab, String)] = [ + (.workspaces, "square.stack"), + (.explorer, "folder"), + (.search, "magnifyingglass"), + ] + + var body: some View { + VStack(spacing: 0) { + // Tab buttons + HStack(spacing: 2) { + ForEach(tabs, id: \.0) { tab, icon in + Button { + selected = tab + } label: { + Image(systemName: icon) + .font(.system(size: 12, weight: selected == tab ? .semibold : .regular)) + .frame(maxWidth: .infinity) + .frame(height: 24) + .foregroundStyle(selected == tab ? .primary : .tertiary) + .background( + selected == tab + ? RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08)) + : nil + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 6) + } + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 273cc43a2..be01d9176 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3598,6 +3598,21 @@ class TabManager: ObservableObject { return browserPanel.id } + /// Open an editor panel in the currently focused pane showing the given directory. + @discardableResult + func openEditor(rootPath: String, filePath: String? = nil, focus: Bool = true) -> UUID? { + guard let tabId = selectedTabId, + let workspace = tabs.first(where: { $0.id == tabId }) else { return nil } + guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first, + let editorPanel = workspace.newEditorSurface(inPane: paneId, rootPath: rootPath, filePath: filePath, focus: focus) else { + return nil + } + if focus { + rememberFocusedSurface(tabId: tabId, surfaceId: editorPanel.id) + } + return editorPanel.id + } + /// Open a browser in the currently focused pane (as a new surface) @discardableResult func openBrowser( diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c6509f464..becb7616f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1920,6 +1920,10 @@ class TerminalController { case "help": return helpText() + // Editor panel commands + case "open_editor": + return openEditor(args) + // Browser panel commands case "open_browser": return openBrowser(args) @@ -13434,6 +13438,34 @@ class TerminalController { return success ? "OK" : "ERROR: Unknown key '\(keyName)'" } + // MARK: - Editor Panel Commands + + private func openEditor(_ args: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldFocus = Self.socketCommandAllowsInAppFocusMutations() + + var result = "ERROR: Failed to create editor panel" + DispatchQueue.main.sync { + let rootPath: String + if trimmed.isEmpty { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + return + } + rootPath = tab.currentDirectory + } else { + rootPath = trimmed + } + + if let panelId = tabManager.openEditor(rootPath: rootPath, focus: shouldFocus) { + result = "OK \(panelId.uuidString)" + } + } + return result + } + // MARK: - Browser Panel Commands private func openBrowser(_ args: String) -> String { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 476204643..cbde33c4c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -385,6 +385,7 @@ extension Workspace { let terminalSnapshot: SessionTerminalPanelSnapshot? let browserSnapshot: SessionBrowserPanelSnapshot? let markdownSnapshot: SessionMarkdownPanelSnapshot? + let editorSnapshot: SessionEditorPanelSnapshot? switch panel.panelType { case .terminal: guard let terminalPanel = panel as? TerminalPanel else { return nil } @@ -408,6 +409,7 @@ extension Workspace { ) browserSnapshot = nil markdownSnapshot = nil + editorSnapshot = nil case .browser: guard let browserPanel = panel as? BrowserPanel else { return nil } terminalSnapshot = nil @@ -422,11 +424,19 @@ extension Workspace { forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) markdownSnapshot = nil + editorSnapshot = nil case .markdown: guard let markdownPanel = panel as? MarkdownPanel else { return nil } terminalSnapshot = nil browserSnapshot = nil markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) + editorSnapshot = nil + case .editor: + guard let edPanel = panel as? EditorPanel else { return nil } + terminalSnapshot = nil + browserSnapshot = nil + markdownSnapshot = nil + editorSnapshot = SessionEditorPanelSnapshot(rootPath: edPanel.rootPath, filePath: edPanel.currentFilePath) } return SessionPanelSnapshot( @@ -442,7 +452,8 @@ extension Workspace { ttyName: ttyName, terminal: terminalSnapshot, browser: browserSnapshot, - markdown: markdownSnapshot + markdown: markdownSnapshot, + editor: editorSnapshot ) } @@ -618,6 +629,20 @@ extension Workspace { } applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) return markdownPanel.id + case .editor: + guard let rootPath = snapshot.editor?.rootPath else { + return nil + } + guard let editorPanel = newEditorSurface( + inPane: paneId, + rootPath: rootPath, + filePath: snapshot.editor?.filePath, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: editorPanel.id) + return editorPanel.id } } @@ -5293,6 +5318,7 @@ final class Workspace: Identifiable, ObservableObject { static let terminal = "terminal" static let browser = "browser" static let markdown = "markdown" + static let editor = "editor" } enum PanelShellActivityState: String { @@ -5765,6 +5791,10 @@ final class Workspace: Identifiable, ObservableObject { panels[panelId] as? MarkdownPanel } + func editorPanel(for panelId: UUID) -> EditorPanel? { + panels[panelId] as? EditorPanel + } + private func surfaceKind(for panel: any Panel) -> String { switch panel.panelType { case .terminal: @@ -5773,6 +5803,8 @@ final class Workspace: Identifiable, ObservableObject { return SurfaceKind.browser case .markdown: return SurfaceKind.markdown + case .editor: + return SurfaceKind.editor } } @@ -7453,6 +7485,81 @@ final class Workspace: Identifiable, ObservableObject { return markdownPanel } + // MARK: - Editor Panel Creation + + /// Create a new editor surface (tab) in the specified pane. + @discardableResult + func newEditorSurface( + inPane paneId: PaneID, + rootPath: String, + filePath: String? = nil, + focus: Bool? = nil + ) -> EditorPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + + let editorPanel = EditorPanel(workspaceId: id, rootPath: rootPath, filePath: filePath) + panels[editorPanel.id] = editorPanel + panelTitles[editorPanel.id] = editorPanel.displayTitle + + guard let newTabId = bonsplitController.createTab( + title: editorPanel.displayTitle, + icon: editorPanel.displayIcon, + kind: SurfaceKind.editor, + isDirty: editorPanel.isDirty, + isLoading: false, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: editorPanel.id) + panelTitles.removeValue(forKey: editorPanel.id) + return nil + } + + surfaceIdToPanelId[newTabId] = editorPanel.id + + if shouldFocusNewTab { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + applyTabSelection(tabId: newTabId, inPane: paneId) + } + + installEditorPanelSubscription(editorPanel) + + return editorPanel + } + + private func installEditorPanelSubscription(_ editorPanel: EditorPanel) { + let subscription = Publishers.CombineLatest3( + editorPanel.$displayTitle.removeDuplicates(), + editorPanel.$isDirty.removeDuplicates(), + editorPanel.$isPreview.removeDuplicates() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self, weak editorPanel] newTitle, isDirty, isPreview in + guard let self, let editorPanel, + let tabId = self.surfaceIdFromPanelId(editorPanel.id), + let existing = self.bonsplitController.tab(tabId) else { return } + + if self.panelTitles[editorPanel.id] != newTitle { + self.panelTitles[editorPanel.id] = newTitle + } + let resolvedTitle = self.resolvedPanelTitle(panelId: editorPanel.id, fallback: newTitle) + let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle + let dirtyUpdate: Bool? = existing.isDirty == isDirty ? nil : isDirty + let italicUpdate: Bool? = existing.isItalic == isPreview ? nil : isPreview + + guard titleUpdate != nil || dirtyUpdate != nil || italicUpdate != nil else { return } + self.bonsplitController.updateTab( + tabId, + title: titleUpdate, + hasCustomTitle: self.panelCustomTitles[editorPanel.id] != nil, + isDirty: dirtyUpdate, + isItalic: italicUpdate + ) + } + panelSubscriptions[editorPanel.id] = subscription + } + /// Tear down all panels in this workspace, freeing their Ghostty surfaces. /// Called before the workspace is removed from TabManager to ensure child /// processes receive SIGHUP even if ARC deallocation is delayed. @@ -10251,11 +10358,14 @@ extension Workspace: BonsplitDelegate { _ = newTerminalSurface(inPane: pane) case "browser": _ = newBrowserSurface(inPane: pane) + case "editor": + _ = newEditorSurface(inPane: pane, rootPath: currentDirectory) default: _ = newTerminalSurface(inPane: pane) } } + func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { switch action { case .rename: diff --git a/ghostty b/ghostty index bc9be90a2..6f773e068 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 +Subproject commit 6f773e068064904dd002572158fed9aa4f9e6ec9 diff --git a/homebrew-cmux b/homebrew-cmux index a5f372ecf..dcfaa081e 160000 --- a/homebrew-cmux +++ b/homebrew-cmux @@ -1 +1 @@ -Subproject commit a5f372ecfa5ee3903af6e1faba0eda096b4f5746 +Subproject commit dcfaa081e5b3e0ad62c5c1a5a4d58f4562f6be71 diff --git a/vendor/bonsplit b/vendor/bonsplit index 1610b457b..b7dda7faf 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 1610b457bc44bb1d50dd246792f8724ce21a7c81 +Subproject commit b7dda7faffbb58fe70e54d4333f196c442cbc39f