diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 02e3f6bc7cd..80cbf26c167 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ '@': resolve('src/renderer/src') } }, - plugins: [react(), tailwindcss()] + plugins: [react(), tailwindcss()], + worker: { + format: 'es' + } } }) diff --git a/package.json b/package.json index 882aed48aba..a277e02834c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@dnd-kit/utilities": "^3.2.2", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@monaco-editor/react": "^4.7.0", "@tanstack/react-virtual": "^3.13.23", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", @@ -43,6 +44,7 @@ "clsx": "^2.1.1", "electron-updater": "^6.8.3", "lucide-react": "^0.577.0", + "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", "radix-ui": "^1.4.3", "shadcn": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1012d762404..5044767bedb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@41.0.3) + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -56,6 +59,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -805,6 +811,16 @@ packages: '@cfworker/json-schema': optional: true + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@mswjs/interceptors@0.41.3': resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} @@ -2082,6 +2098,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -2570,6 +2589,9 @@ packages: os: [darwin] hasBin: true + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -3360,6 +3382,11 @@ packages: resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} engines: {node: ^18.17.0 || >=20.5.0} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -3478,6 +3505,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4074,6 +4104,9 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5134,6 +5167,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@mswjs/interceptors@0.41.3': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -6308,6 +6352,9 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/validate-npm-package-name@4.0.2': {} '@types/verror@1.10.11': @@ -6823,6 +6870,10 @@ snapshots: verror: 1.10.1 optional: true + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -7708,6 +7759,8 @@ snapshots: transitivePeerDependencies: - supports-color + marked@14.0.0: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -7806,6 +7859,11 @@ snapshots: dependencies: minimist: 1.2.8 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3): @@ -8575,6 +8633,8 @@ snapshots: stat-mode@1.0.0: {} + state-local@1.0.7: {} + statuses@2.0.2: {} stdin-discarder@0.2.2: {} diff --git a/src/main/git/status.ts b/src/main/git/status.ts new file mode 100644 index 00000000000..da8dd55c56a --- /dev/null +++ b/src/main/git/status.ts @@ -0,0 +1,168 @@ +import { execFile } from 'child_process' +import { readFile } from 'fs/promises' +import { promisify } from 'util' +import { join } from 'path' +import type { GitStatusEntry, GitFileStatus, GitDiffResult } from '../../shared/types' + +const execFileAsync = promisify(execFile) + +/** + * Parse `git status --porcelain=v2` output into structured entries. + */ +export async function getStatus(worktreePath: string): Promise { + const entries: GitStatusEntry[] = [] + + try { + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain=v2', '--untracked-files=all'], + { cwd: worktreePath, encoding: 'utf-8' } + ) + + for (const line of stdout.split('\n')) { + if (!line) continue + + if (line.startsWith('1 ') || line.startsWith('2 ')) { + // Changed entries: "1 XY sub mH mI mW hH path" or "2 XY sub mH mI mW hH X\tscore\tpath\torigPath" + const parts = line.split(' ') + const xy = parts[1] + const indexStatus = xy[0] + const worktreeStatus = xy[1] + + if (line.startsWith('2 ')) { + // Rename entry - tab separated at the end + const tabParts = line.split('\t') + const path = tabParts[1] + const oldPath = tabParts[2] + if (indexStatus !== '.') { + entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged', oldPath }) + } + if (worktreeStatus !== '.') { + entries.push({ + path, + status: parseStatusChar(worktreeStatus), + area: 'unstaged', + oldPath + }) + } + } else { + // Regular change entry + const path = parts.slice(8).join(' ') + if (indexStatus !== '.') { + entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged' }) + } + if (worktreeStatus !== '.') { + entries.push({ path, status: parseStatusChar(worktreeStatus), area: 'unstaged' }) + } + } + } else if (line.startsWith('? ')) { + // Untracked file + const path = line.slice(2) + entries.push({ path, status: 'untracked', area: 'untracked' }) + } + } + } catch { + // Not a git repo or git not available + } + + return entries +} + +function parseStatusChar(char: string): GitFileStatus { + switch (char) { + case 'M': + return 'modified' + case 'A': + return 'added' + case 'D': + return 'deleted' + case 'R': + return 'renamed' + case 'C': + return 'copied' + default: + return 'modified' + } +} + +/** + * Get original and modified content for diffing a file. + */ +export async function getDiff( + worktreePath: string, + filePath: string, + staged: boolean +): Promise { + let originalContent = '' + let modifiedContent = '' + + try { + // Get original content (HEAD version) + try { + const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], { + cwd: worktreePath, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024 + }) + originalContent = stdout + } catch { + // File is new (no HEAD version) + originalContent = '' + } + + if (staged) { + // Staged: modified is the index version + try { + const { stdout } = await execFileAsync('git', ['show', `:${filePath}`], { + cwd: worktreePath, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024 + }) + modifiedContent = stdout + } catch { + modifiedContent = '' + } + } else { + // Unstaged: modified is the working tree version + try { + modifiedContent = await readFile(join(worktreePath, filePath), 'utf-8') + } catch { + modifiedContent = '' + } + } + } catch { + // Fallback + } + + return { originalContent, modifiedContent } +} + +/** + * Stage a file. + */ +export async function stageFile(worktreePath: string, filePath: string): Promise { + await execFileAsync('git', ['add', '--', filePath], { + cwd: worktreePath, + encoding: 'utf-8' + }) +} + +/** + * Unstage a file. + */ +export async function unstageFile(worktreePath: string, filePath: string): Promise { + await execFileAsync('git', ['restore', '--staged', '--', filePath], { + cwd: worktreePath, + encoding: 'utf-8' + }) +} + +/** + * Discard working tree changes for a file. + */ +export async function discardChanges(worktreePath: string, filePath: string): Promise { + await execFileAsync('git', ['checkout', '--', filePath], { + cwd: worktreePath, + encoding: 'utf-8' + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index efe63f9d4fd..e19014b687f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { registerSettingsHandlers } from './ipc/settings' import { registerShellHandlers } from './ipc/shell' import { registerSessionHandlers } from './ipc/session' import { registerUIHandlers } from './ipc/ui' +import { registerFilesystemHandlers } from './ipc/filesystem' import { warmSystemFontFamilies } from './system-fonts' import { setupAutoUpdater, @@ -223,6 +224,7 @@ app.whenReady().then(() => { registerShellHandlers() registerSessionHandlers(store) registerUIHandlers(store) + registerFilesystemHandlers(store) warmSystemFontFamilies() setupAutoUpdater(mainWindow) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts new file mode 100644 index 00000000000..b1c29f27ea1 --- /dev/null +++ b/src/main/ipc/filesystem.ts @@ -0,0 +1,165 @@ +import { ipcMain } from 'electron' +import { readdir, readFile, writeFile, stat } from 'fs/promises' +import { resolve } from 'path' +import type { Store } from '../persistence' +import type { DirEntry, GitStatusEntry, GitDiffResult } from '../../shared/types' +import { getStatus, getDiff, stageFile, unstageFile, discardChanges } from '../git/status' + +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB + +/** + * Validate that a path is within a known worktree directory. + */ +function isPathAllowed(targetPath: string, store: Store): boolean { + const resolvedTarget = resolve(targetPath) + const repos = store.getRepos() + + for (const repo of repos) { + // Allow paths within the repo itself + if ( + resolvedTarget.startsWith(resolve(repo.path) + '/') || + resolvedTarget === resolve(repo.path) + ) { + return true + } + } + + // Also check the workspace directory from settings + const settings = store.getSettings() + if (settings.workspaceDir) { + const resolvedWorkspace = resolve(settings.workspaceDir) + if ( + resolvedTarget.startsWith(resolvedWorkspace + '/') || + resolvedTarget === resolvedWorkspace + ) { + return true + } + } + + return false +} + +/** + * Check if a buffer appears to be binary (contains null bytes in first 8KB). + */ +function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, 8192) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) return true + } + return false +} + +export function registerFilesystemHandlers(store: Store): void { + // ─── Filesystem ───────────────────────────────────────── + ipcMain.handle('fs:readDir', async (_event, args: { dirPath: string }): Promise => { + if (!isPathAllowed(args.dirPath, store)) { + throw new Error('Access denied: path is outside allowed directories') + } + + const entries = await readdir(args.dirPath, { withFileTypes: true }) + return entries + .map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + .sort((a, b) => { + // Directories first, then alphabetical + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + return a.name.localeCompare(b.name) + }) + }) + + ipcMain.handle( + 'fs:readFile', + async (_event, args: { filePath: string }): Promise<{ content: string; isBinary: boolean }> => { + if (!isPathAllowed(args.filePath, store)) { + throw new Error('Access denied: path is outside allowed directories') + } + + const stats = await stat(args.filePath) + if (stats.size > MAX_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + + const buffer = await readFile(args.filePath) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + + return { content: buffer.toString('utf-8'), isBinary: false } + } + ) + + ipcMain.handle( + 'fs:writeFile', + async (_event, args: { filePath: string; content: string }): Promise => { + if (!isPathAllowed(args.filePath, store)) { + throw new Error('Access denied: path is outside allowed directories') + } + + await writeFile(args.filePath, args.content, 'utf-8') + } + ) + + ipcMain.handle( + 'fs:stat', + async ( + _event, + args: { filePath: string } + ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { + if (!isPathAllowed(args.filePath, store)) { + throw new Error('Access denied: path is outside allowed directories') + } + + const stats = await stat(args.filePath) + return { + size: stats.size, + isDirectory: stats.isDirectory(), + mtime: stats.mtimeMs + } + } + ) + + // ─── Git operations ───────────────────────────────────── + ipcMain.handle( + 'git:status', + async (_event, args: { worktreePath: string }): Promise => { + return getStatus(args.worktreePath) + } + ) + + ipcMain.handle( + 'git:diff', + async ( + _event, + args: { worktreePath: string; filePath: string; staged: boolean } + ): Promise => { + return getDiff(args.worktreePath, args.filePath, args.staged) + } + ) + + ipcMain.handle( + 'git:stage', + async (_event, args: { worktreePath: string; filePath: string }): Promise => { + await stageFile(args.worktreePath, args.filePath) + } + ) + + ipcMain.handle( + 'git:unstage', + async (_event, args: { worktreePath: string; filePath: string }): Promise => { + await unstageFile(args.worktreePath, args.filePath) + } + ) + + ipcMain.handle( + 'git:discard', + async (_event, args: { worktreePath: string; filePath: string }): Promise => { + await discardChanges(args.worktreePath, args.filePath) + } + ) +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a9168f1a3bc..f17ba5efd44 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -9,7 +9,10 @@ import type { OrcaHooks, PersistedUIState, WorkspaceSessionState, - UpdateStatus + UpdateStatus, + DirEntry, + GitStatusEntry, + GitDiffResult } from '../../shared/types' interface ReposApi { @@ -101,6 +104,27 @@ interface UIApi { setZoomLevel: (level: number) => void } +interface FsApi { + readDir: (args: { dirPath: string }) => Promise + readFile: (args: { filePath: string }) => Promise<{ content: string; isBinary: boolean }> + writeFile: (args: { filePath: string; content: string }) => Promise + stat: (args: { + filePath: string + }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> +} + +interface GitApi { + status: (args: { worktreePath: string }) => Promise + diff: (args: { + worktreePath: string + filePath: string + staged: boolean + }) => Promise + stage: (args: { worktreePath: string; filePath: string }) => Promise + unstage: (args: { worktreePath: string; filePath: string }) => Promise + discard: (args: { worktreePath: string; filePath: string }) => Promise +} + interface Api { repos: ReposApi worktrees: WorktreesApi @@ -112,6 +136,8 @@ interface Api { cache: CacheApi session: SessionApi updater: UpdaterApi + fs: FsApi + git: GitApi ui: UIApi } diff --git a/src/preload/index.ts b/src/preload/index.ts index 215350051b4..922aa29cf0b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -173,6 +173,38 @@ const api = { } }, + fs: { + readDir: (args: { + dirPath: string + }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => + ipcRenderer.invoke('fs:readDir', args), + readFile: (args: { filePath: string }): Promise<{ content: string; isBinary: boolean }> => + ipcRenderer.invoke('fs:readFile', args), + writeFile: (args: { filePath: string; content: string }): Promise => + ipcRenderer.invoke('fs:writeFile', args), + stat: (args: { + filePath: string + }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => + ipcRenderer.invoke('fs:stat', args) + }, + + git: { + status: (args: { worktreePath: string }): Promise => + ipcRenderer.invoke('git:status', args), + diff: (args: { + worktreePath: string + filePath: string + staged: boolean + }): Promise<{ originalContent: string; modifiedContent: string }> => + ipcRenderer.invoke('git:diff', args), + stage: (args: { worktreePath: string; filePath: string }): Promise => + ipcRenderer.invoke('git:stage', args), + unstage: (args: { worktreePath: string; filePath: string }): Promise => + ipcRenderer.invoke('git:unstage', args), + discard: (args: { worktreePath: string; filePath: string }): Promise => + ipcRenderer.invoke('git:discard', args) + }, + ui: { get: (): Promise => ipcRenderer.invoke('ui:get'), set: (args: Record): Promise => ipcRenderer.invoke('ui:set', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 57dbc621f99..f907b051f2f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { Toaster } from 'sonner' -import { Minimize2, PanelLeft } from 'lucide-react' +import { Minimize2, PanelLeft, PanelRight } from 'lucide-react' import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { useAppStore } from './store' @@ -9,6 +9,7 @@ import Sidebar from './components/Sidebar' import Terminal from './components/Terminal' import Landing from './components/Landing' import Settings from './components/Settings' +import RightSidebar from './components/right-sidebar' function App(): React.JSX.Element { const toggleSidebar = useAppStore((s) => s.toggleSidebar) @@ -34,6 +35,13 @@ function App(): React.JSX.Element { const sortBy = useAppStore((s) => s.sortBy) const persistedUIReady = useAppStore((s) => s.persistedUIReady) + // Right sidebar + editor state + const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar) + const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) + const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) + const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) + const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) + // Subscribe to IPC push events useIpcEvents() @@ -61,6 +69,7 @@ function App(): React.JSX.Element { lastActiveRepoId: null, lastActiveWorktreeId: null, sidebarWidth: 280, + rightSidebarWidth: 350, groupBy: 'none', sortBy: 'name', uiZoomLevel: 0 @@ -119,13 +128,14 @@ function App(): React.JSX.Element { const timer = window.setTimeout(() => { void window.api.ui.set({ sidebarWidth, + rightSidebarWidth, groupBy, sortBy }) }, 150) return () => window.clearTimeout(timer) - }, [persistedUIReady, sidebarWidth, groupBy, sortBy]) + }, [persistedUIReady, sidebarWidth, rightSidebarWidth, groupBy, sortBy]) // Apply theme to document useEffect(() => { @@ -179,16 +189,36 @@ function App(): React.JSX.Element { useEffect(() => { const onKeyDown = (e: KeyboardEvent): void => { if (e.repeat) return - if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return - if (e.key.toLowerCase() !== 'n') return - if (repos.length === 0) return - e.preventDefault() - openModal('create-worktree') + if (!e.metaKey) return + + // Cmd+N — create worktree + if (!e.ctrlKey && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') { + if (repos.length === 0) return + e.preventDefault() + openModal('create-worktree') + return + } + + // Cmd+Shift+E — toggle right sidebar / explorer tab + if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'e') { + e.preventDefault() + setRightSidebarTab('explorer') + setRightSidebarOpen(true) + return + } + + // Cmd+Shift+G — toggle right sidebar / source control tab + if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'g') { + e.preventDefault() + setRightSidebarTab('source-control') + setRightSidebarOpen(true) + return + } } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [openModal, repos.length]) + }, [openModal, repos.length, setRightSidebarTab, setRightSidebarOpen]) return (
@@ -216,6 +246,16 @@ function App(): React.JSX.Element { )} +
{showSidebar ? : null} @@ -233,6 +273,7 @@ function App(): React.JSX.Element { )} {activeView === 'settings' ? : !activeWorktreeId ? : null}
+ {showSidebar && rightSidebarOpen ? : null} void + onClose: () => void + onCloseOthers: () => void + onCloseAll: () => void +}): React.JSX.Element { + const fileName = file.relativePath.split('/').pop() ?? file.relativePath + const isDiff = file.mode === 'diff' + const [menuOpen, setMenuOpen] = useState(false) + const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) + + useEffect(() => { + const closeMenu = (): void => setMenuOpen(false) + window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) + return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) + }, []) + + return ( + <> +
{ + event.preventDefault() + window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) + setMenuPoint({ x: event.clientX, y: event.clientY }) + setMenuOpen(true) + }} + > +
{ + if (e.button === 1) { + e.preventDefault() + e.stopPropagation() + onClose() + } + }} + > + {isDiff ? ( + + ) : ( + + )} + {file.isDirty && ( + + )} + + {isDiff + ? file.relativePath === 'All Changes' + ? 'All Changes' + : `${fileName} (diff${file.diffStaged ? ' staged' : ''})` + : fileName} + + +
+
+ + + + + + + + + ) } diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx new file mode 100644 index 00000000000..beeb935598f --- /dev/null +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -0,0 +1,209 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { DiffEditor } from '@monaco-editor/react' +import { useAppStore } from '@/store' +import { detectLanguage } from '@/lib/language-detect' +import '@/lib/monaco-setup' +import { cn } from '@/lib/utils' +import type { GitStatusEntry } from '../../../../shared/types' + +interface DiffSection { + entry: GitStatusEntry + originalContent: string + modifiedContent: string + collapsed: boolean + loading: boolean +} + +export default function CombinedDiffViewer({ + worktreePath +}: { + worktreePath: string +}): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const isDark = + settings?.theme === 'dark' || + (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + + const [sections, setSections] = useState([]) + const [sideBySide, setSideBySide] = useState(true) + + // Load all changed files + useEffect(() => { + let cancelled = false + + void (async () => { + try { + const entries = (await window.api.git.status({ worktreePath })) as GitStatusEntry[] + // Filter to only staged and unstaged (not untracked) + const changed = entries.filter((e) => e.area !== 'untracked') + + if (cancelled) return + + // Initialize sections + const initialSections: DiffSection[] = changed.map((entry) => ({ + entry, + originalContent: '', + modifiedContent: '', + collapsed: false, + loading: true + })) + setSections(initialSections) + + // Load diffs in parallel + const results = await Promise.all( + changed.map(async (entry) => { + try { + const diff = (await window.api.git.diff({ + worktreePath, + filePath: entry.path, + staged: entry.area === 'staged' + })) as { originalContent: string; modifiedContent: string } + return diff + } catch { + return { originalContent: '', modifiedContent: '' } + } + }) + ) + + if (cancelled) return + + setSections((prev) => + prev.map((section, i) => ({ + ...section, + originalContent: results[i].originalContent, + modifiedContent: results[i].modifiedContent, + loading: false + })) + ) + } catch { + // ignore + } + })() + + return () => { + cancelled = true + } + }, [worktreePath]) + + const toggleSection = useCallback((index: number) => { + setSections((prev) => prev.map((s, i) => (i === index ? { ...s, collapsed: !s.collapsed } : s))) + }, []) + + if (sections.length === 0) { + return ( +
+ No changes to display +
+ ) + } + + return ( +
+ {/* Toolbar */} +
+ {sections.length} changed files +
+ + + +
+
+ + {/* Scrollable diff sections */} +
+ {sections.map((section, index) => { + const language = detectLanguage(section.entry.path) + const fileName = section.entry.path.split('/').pop() ?? section.entry.path + const dirPath = section.entry.path.includes('/') + ? section.entry.path.slice(0, section.entry.path.lastIndexOf('/')) + : '' + + return ( +
+ {/* Section header */} + + + {/* Diff content */} + {!section.collapsed && ( +
+ {section.loading ? ( +
+ Loading... +
+ ) : ( + + )} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx new file mode 100644 index 00000000000..0368d6fefc9 --- /dev/null +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -0,0 +1,69 @@ +import React, { useState, useCallback } from 'react' +import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' +import { Columns2, Rows2 } from 'lucide-react' +import { useAppStore } from '@/store' +import '@/lib/monaco-setup' + +interface DiffViewerProps { + originalContent: string + modifiedContent: string + language: string + filePath: string +} + +export default function DiffViewer({ + originalContent, + modifiedContent, + language, + filePath +}: DiffViewerProps): React.JSX.Element { + const [sideBySide, setSideBySide] = useState(true) + const settings = useAppStore((s) => s.settings) + const isDark = + settings?.theme === 'dark' || + (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + + const handleMount: DiffOnMount = useCallback((editor) => { + editor.focus() + }, []) + + return ( +
+ {/* Toolbar */} +
+ {filePath} + +
+ + {/* Diff Editor */} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx new file mode 100644 index 00000000000..b513ffeacf0 --- /dev/null +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useState, lazy, Suspense } from 'react' +import { useAppStore } from '@/store' + +const MonacoEditor = lazy(() => import('./MonacoEditor')) +const DiffViewer = lazy(() => import('./DiffViewer')) +const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer')) + +interface FileContent { + content: string + isBinary: boolean +} + +interface DiffContent { + originalContent: string + modifiedContent: string +} + +export default function EditorPanel(): React.JSX.Element { + const openFiles = useAppStore((s) => s.openFiles) + const activeFileId = useAppStore((s) => s.activeFileId) + const markFileDirty = useAppStore((s) => s.markFileDirty) + + const activeFile = openFiles.find((f) => f.id === activeFileId) ?? null + + const [fileContents, setFileContents] = useState>({}) + const [diffContents, setDiffContents] = useState>({}) + const [editBuffers, setEditBuffers] = useState>({}) + + // Load file content when active file changes + useEffect(() => { + if (!activeFile) return + if (activeFile.mode === 'edit') { + if (fileContents[activeFile.id]) return + void loadFileContent(activeFile.filePath, activeFile.id) + } else if (activeFile.mode === 'diff' && activeFile.diffStaged !== undefined) { + if (diffContents[activeFile.id]) return + void loadDiffContent(activeFile) + } + }, [activeFile?.id]) // eslint-disable-line react-hooks/exhaustive-deps + + const loadFileContent = async (filePath: string, id: string): Promise => { + try { + const result = (await window.api.fs.readFile({ filePath })) as FileContent + setFileContents((prev) => ({ ...prev, [id]: result })) + } catch (err) { + setFileContents((prev) => ({ + ...prev, + [id]: { content: `Error loading file: ${err}`, isBinary: false } + })) + } + } + + const loadDiffContent = async (file: typeof activeFile): Promise => { + if (!file) return + try { + // Extract worktree path from absolute file path and relative path + const worktreePath = file.filePath.slice( + 0, + file.filePath.length - file.relativePath.length - 1 + ) + const result = (await window.api.git.diff({ + worktreePath, + filePath: file.relativePath, + staged: file.diffStaged ?? false + })) as DiffContent + setDiffContents((prev) => ({ ...prev, [file.id]: result })) + } catch (err) { + setDiffContents((prev) => ({ + ...prev, + [file.id]: { originalContent: '', modifiedContent: `Error loading diff: ${err}` } + })) + } + } + + const handleContentChange = useCallback( + (content: string) => { + if (!activeFile) return + setEditBuffers((prev) => ({ ...prev, [activeFile.id]: content })) + // Compare against saved content to determine dirty state + const saved = fileContents[activeFile.id]?.content ?? '' + markFileDirty(activeFile.id, content !== saved) + }, + [activeFile, markFileDirty, fileContents] + ) + + const handleSave = useCallback( + async (content: string) => { + if (!activeFile) return + try { + await window.api.fs.writeFile({ filePath: activeFile.filePath, content }) + markFileDirty(activeFile.id, false) + setFileContents((prev) => ({ + ...prev, + [activeFile.id]: { content, isBinary: false } + })) + } catch (err) { + console.error('Save failed:', err) + } + }, + [activeFile, markFileDirty] + ) + + // Handle save-and-close events from the save confirmation dialog + useEffect(() => { + const handler = async (e: Event): Promise => { + const { fileId } = (e as CustomEvent).detail as { fileId: string } + const file = useAppStore.getState().openFiles.find((f) => f.id === fileId) + if (!file) return + const buffer = editBuffers[fileId] + if (buffer !== undefined) { + try { + await window.api.fs.writeFile({ filePath: file.filePath, content: buffer }) + markFileDirty(fileId, false) + setFileContents((prev) => ({ + ...prev, + [fileId]: { content: buffer, isBinary: false } + })) + } catch (err) { + console.error('Save failed:', err) + return // Don't close if save fails + } + } + useAppStore.getState().closeFile(fileId) + } + window.addEventListener('orca:save-and-close', handler as EventListener) + return () => window.removeEventListener('orca:save-and-close', handler as EventListener) + }, [editBuffers, markFileDirty]) + + // Clean up content caches when files are closed + useEffect(() => { + const openIds = new Set(openFiles.map((f) => f.id)) + setFileContents((prev) => { + const next: Record = {} + for (const [k, v] of Object.entries(prev)) { + if (openIds.has(k)) next[k] = v + } + return next + }) + setDiffContents((prev) => { + const next: Record = {} + for (const [k, v] of Object.entries(prev)) { + if (openIds.has(k)) next[k] = v + } + return next + }) + setEditBuffers((prev) => { + const next: Record = {} + for (const [k, v] of Object.entries(prev)) { + if (openIds.has(k)) next[k] = v + } + return next + }) + }, [openFiles]) + + if (!activeFile) return <> + + const isCombinedDiff = activeFile.mode === 'diff' && activeFile.diffStaged === undefined + + const loadingFallback = ( +
+ Loading editor... +
+ ) + + return ( +
+ + {isCombinedDiff ? ( + + ) : activeFile.mode === 'edit' ? ( + (() => { + const fc = fileContents[activeFile.id] + if (!fc) { + return ( +
+ Loading... +
+ ) + } + if (fc.isBinary) { + return ( +
+ Binary file — cannot display +
+ ) + } + return ( + + ) + })() + ) : ( + (() => { + const dc = diffContents[activeFile.id] + if (!dc) { + return ( +
+ Loading diff... +
+ ) + } + return ( + + ) + })() + )} +
+
+ ) +} diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx new file mode 100644 index 00000000000..010f9529af6 --- /dev/null +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -0,0 +1,86 @@ +import React, { useRef, useCallback, useEffect } from 'react' +import Editor, { type OnMount } from '@monaco-editor/react' +import type { editor } from 'monaco-editor' +import { useAppStore } from '@/store' +import '@/lib/monaco-setup' + +interface MonacoEditorProps { + filePath: string + content: string + language: string + onContentChange: (content: string) => void + onSave: (content: string) => void +} + +export default function MonacoEditor({ + filePath, + content, + language, + onContentChange, + onSave +}: MonacoEditorProps): React.JSX.Element { + const editorRef = useRef(null) + const settings = useAppStore((s) => s.settings) + const isDark = + settings?.theme === 'dark' || + (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + + const handleMount: OnMount = useCallback( + (editor, monaco) => { + editorRef.current = editor + + // Add Cmd+S save keybinding + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + const value = editor.getValue() + onSave(value) + }) + + editor.focus() + }, + [onSave] + ) + + const handleChange = useCallback( + (value: string | undefined) => { + if (value !== undefined) { + onContentChange(value) + } + }, + [onContentChange] + ) + + // Update editor options when settings change + useEffect(() => { + if (!editorRef.current || !settings) return + editorRef.current.updateOptions({ + fontSize: settings.terminalFontSize, + fontFamily: settings.terminalFontFamily || 'monospace' + }) + }, [settings?.terminalFontSize, settings?.terminalFontFamily]) + + return ( + + ) +} diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx new file mode 100644 index 00000000000..2588ca2da97 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -0,0 +1,230 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { ChevronRight, File, Folder, FolderOpen, Loader2 } from 'lucide-react' +import { useAppStore } from '@/store' +import { detectLanguage } from '@/lib/language-detect' +import { cn } from '@/lib/utils' + +interface TreeNode { + name: string + path: string // absolute path + relativePath: string + isDirectory: boolean + depth: number +} + +interface DirCache { + children: TreeNode[] + loading: boolean +} + +export default function FileExplorer(): React.JSX.Element { + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const expandedDirs = useAppStore((s) => s.expandedDirs) + const toggleDir = useAppStore((s) => s.toggleDir) + const openFile = useAppStore((s) => s.openFile) + const activeFileId = useAppStore((s) => s.activeFileId) + + // Find active worktree path + const worktreePath = useMemo(() => { + if (!activeWorktreeId) return null + for (const worktrees of Object.values(worktreesByRepo)) { + const wt = worktrees.find((w) => w.id === activeWorktreeId) + if (wt) return wt.path + } + return null + }, [activeWorktreeId, worktreesByRepo]) + + const [dirCache, setDirCache] = useState>({}) + const scrollRef = useRef(null) + + const expanded = useMemo( + () => + activeWorktreeId ? (expandedDirs[activeWorktreeId] ?? new Set()) : new Set(), + [activeWorktreeId, expandedDirs] + ) + + // Load directory contents + const loadDir = useCallback( + async (dirPath: string, depth: number) => { + if (dirCache[dirPath]?.children.length > 0 || dirCache[dirPath]?.loading) return + + setDirCache((prev) => ({ + ...prev, + [dirPath]: { children: prev[dirPath]?.children ?? [], loading: true } + })) + + try { + const entries = (await window.api.fs.readDir({ dirPath })) as { + name: string + isDirectory: boolean + }[] + + const children: TreeNode[] = entries + .filter((e) => !e.name.startsWith('.') || e.name === '.github') + .filter((e) => e.name !== 'node_modules' && e.name !== '.git') + .map((e) => ({ + name: e.name, + path: `${dirPath}/${e.name}`, + relativePath: worktreePath + ? `${dirPath}/${e.name}`.slice(worktreePath.length + 1) + : e.name, + isDirectory: e.isDirectory, + depth: depth + 1 + })) + + setDirCache((prev) => ({ + ...prev, + [dirPath]: { children, loading: false } + })) + } catch { + setDirCache((prev) => ({ + ...prev, + [dirPath]: { children: [], loading: false } + })) + } + }, + [dirCache, worktreePath] + ) + + // Load root when worktree changes + useEffect(() => { + if (!worktreePath) return + setDirCache({}) + void loadDir(worktreePath, -1) + }, [worktreePath]) // eslint-disable-line react-hooks/exhaustive-deps + + // Load expanded directories + useEffect(() => { + for (const dirPath of expanded) { + if (!dirCache[dirPath]?.children.length && !dirCache[dirPath]?.loading) { + const depth = worktreePath + ? dirPath.slice(worktreePath.length + 1).split('/').length - 1 + : 0 + void loadDir(dirPath, depth) + } + } + }, [expanded]) // eslint-disable-line react-hooks/exhaustive-deps + + // Flatten tree into visible rows + const flatRows = useMemo(() => { + if (!worktreePath) return [] + + const result: TreeNode[] = [] + + const addChildren = (parentPath: string): void => { + const cached = dirCache[parentPath] + if (!cached?.children) return + + for (const child of cached.children) { + result.push(child) + if (child.isDirectory && expanded.has(child.path)) { + addChildren(child.path) + } + } + } + + addChildren(worktreePath) + return result + }, [worktreePath, dirCache, expanded]) + + const virtualizer = useVirtualizer({ + count: flatRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 26, + overscan: 20, + getItemKey: (index) => flatRows[index].path + }) + + const handleClick = useCallback( + (node: TreeNode) => { + if (!activeWorktreeId) return + + if (node.isDirectory) { + toggleDir(activeWorktreeId, node.path) + } else { + openFile({ + filePath: node.path, + relativePath: node.relativePath, + worktreeId: activeWorktreeId, + language: detectLanguage(node.name), + mode: 'edit' + }) + } + }, + [activeWorktreeId, toggleDir, openFile] + ) + + if (!worktreePath) { + return ( +
+ Select a worktree to browse files +
+ ) + } + + if (flatRows.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+
+ {virtualizer.getVirtualItems().map((vItem) => { + const node = flatRows[vItem.index] + const isExpanded = expanded.has(node.path) + const isLoading = node.isDirectory && dirCache[node.path]?.loading + const isActive = activeFileId === node.path + + return ( +
+ +
+ ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx new file mode 100644 index 00000000000..d4423b03998 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -0,0 +1,317 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ChevronDown, + Minus, + Plus, + Undo2, + FileEdit, + FilePlus, + FileMinus, + FileQuestion, + ArrowRightLeft, + GitCompareArrows +} from 'lucide-react' +import { useAppStore } from '@/store' +import { detectLanguage } from '@/lib/language-detect' +import { cn } from '@/lib/utils' +import type { GitStatusEntry, GitStagingArea } from '../../../../shared/types' + +const STATUS_ICONS: Record> = { + modified: FileEdit, + added: FilePlus, + deleted: FileMinus, + renamed: ArrowRightLeft, + untracked: FileQuestion, + copied: FilePlus +} + +const STATUS_LABELS: Record = { + modified: 'M', + added: 'A', + deleted: 'D', + renamed: 'R', + untracked: 'U', + copied: 'C' +} + +const STATUS_COLORS: Record = { + modified: 'text-amber-500', + added: 'text-green-500', + deleted: 'text-red-500', + renamed: 'text-blue-500', + untracked: 'text-green-600', + copied: 'text-blue-400' +} + +const SECTION_ORDER: GitStagingArea[] = ['staged', 'unstaged', 'untracked'] +const SECTION_LABELS: Record = { + staged: 'Staged Changes', + unstaged: 'Changes', + untracked: 'Untracked Files' +} + +export default function SourceControl(): React.JSX.Element { + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) + const setGitStatus = useAppStore((s) => s.setGitStatus) + const openDiff = useAppStore((s) => s.openDiff) + const openAllDiffs = useAppStore((s) => s.openAllDiffs) + + const [collapsedSections, setCollapsedSections] = useState>(new Set()) + const pollRef = useRef | null>(null) + + // Find active worktree path + const worktreePath = useMemo(() => { + if (!activeWorktreeId) return null + for (const worktrees of Object.values(worktreesByRepo)) { + const wt = worktrees.find((w) => w.id === activeWorktreeId) + if (wt) return wt.path + } + return null + }, [activeWorktreeId, worktreesByRepo]) + + const fetchStatus = useCallback(async () => { + if (!activeWorktreeId || !worktreePath) return + try { + const entries = (await window.api.git.status({ worktreePath })) as GitStatusEntry[] + setGitStatus(activeWorktreeId, entries) + } catch { + // ignore + } + }, [activeWorktreeId, worktreePath, setGitStatus]) + + // Poll git status every 3 seconds + useEffect(() => { + void fetchStatus() + pollRef.current = setInterval(() => void fetchStatus(), 3000) + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, [fetchStatus]) + + const entries = useMemo( + () => (activeWorktreeId ? (gitStatusByWorktree[activeWorktreeId] ?? []) : []), + [activeWorktreeId, gitStatusByWorktree] + ) + + const grouped = useMemo(() => { + const groups: Record = { + staged: [], + unstaged: [], + untracked: [] + } + for (const entry of entries) { + groups[entry.area].push(entry) + } + return groups + }, [entries]) + + const toggleSection = useCallback((section: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev) + if (next.has(section)) next.delete(section) + else next.add(section) + return next + }) + }, []) + + const handleStage = useCallback( + async (filePath: string) => { + if (!worktreePath) return + try { + await window.api.git.stage({ worktreePath, filePath }) + void fetchStatus() + } catch { + // ignore + } + }, + [worktreePath, fetchStatus] + ) + + const handleUnstage = useCallback( + async (filePath: string) => { + if (!worktreePath) return + try { + await window.api.git.unstage({ worktreePath, filePath }) + void fetchStatus() + } catch { + // ignore + } + }, + [worktreePath, fetchStatus] + ) + + const handleDiscard = useCallback( + async (filePath: string) => { + if (!worktreePath) return + try { + await window.api.git.discard({ worktreePath, filePath }) + void fetchStatus() + } catch { + // ignore + } + }, + [worktreePath, fetchStatus] + ) + + const handleViewAllChanges = useCallback(() => { + if (!activeWorktreeId || !worktreePath) return + openAllDiffs(activeWorktreeId, worktreePath) + }, [activeWorktreeId, worktreePath, openAllDiffs]) + + const handleOpenDiff = useCallback( + (entry: GitStatusEntry) => { + if (!activeWorktreeId) return + const language = detectLanguage(entry.path) + const absolutePath = worktreePath ? `${worktreePath}/${entry.path}` : entry.path + openDiff(activeWorktreeId, absolutePath, entry.path, language, entry.area === 'staged') + }, + [activeWorktreeId, worktreePath, openDiff] + ) + + if (!worktreePath) { + return ( +
+ Select a worktree to view changes +
+ ) + } + + if (entries.length === 0) { + return ( +
+ No changes detected +
+ ) + } + + return ( +
+ {/* View All Changes button */} + + + {SECTION_ORDER.map((area) => { + const items = grouped[area] + if (items.length === 0) return null + const isCollapsed = collapsedSections.has(area) + + return ( +
+ {/* Section header */} + + + {/* File entries */} + {!isCollapsed && + items.map((entry) => { + const StatusIcon = STATUS_ICONS[entry.status] ?? FileQuestion + const fileName = entry.path.split('/').pop() ?? entry.path + const dirPath = entry.path.includes('/') + ? entry.path.slice(0, entry.path.lastIndexOf('/')) + : '' + + return ( +
handleOpenDiff(entry)} + > + + + {fileName} + {dirPath && ( + {dirPath} + )} + + + {STATUS_LABELS[entry.status]} + + + {/* Action buttons */} +
+ {area === 'unstaged' || area === 'untracked' ? ( + <> + {area === 'unstaged' && ( + { + e.stopPropagation() + void handleDiscard(entry.path) + }} + /> + )} + { + e.stopPropagation() + void handleStage(entry.path) + }} + /> + + ) : ( + { + e.stopPropagation() + void handleUnstage(entry.path) + }} + /> + )} +
+
+ ) + })} +
+ ) + })} +
+ ) +} + +function ActionButton({ + icon: Icon, + title, + onClick +}: { + icon: React.ComponentType<{ className?: string }> + title: string + onClick: (e: React.MouseEvent) => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx new file mode 100644 index 00000000000..0847235c958 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useRef, useEffect } from 'react' +import { useAppStore } from '@/store' +import FileExplorer from './FileExplorer' +import SourceControl from './SourceControl' + +const MIN_WIDTH = 220 +const MAX_WIDTH = 500 + +export default function RightSidebar(): React.JSX.Element { + const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) + const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) + const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth) + const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) + const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) + + // ─── Resize logic (handle on LEFT edge) ──────────── + const isResizing = useRef(false) + const startX = useRef(0) + const startWidth = useRef(0) + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing.current) return + // Dragging left = larger width (opposite of left sidebar) + const delta = startX.current - e.clientX + const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta)) + setRightSidebarWidth(next) + }, + [setRightSidebarWidth] + ) + + const handleMouseUp = useCallback(() => { + isResizing.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + }, []) + + useEffect(() => { + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + }, [handleMouseMove, handleMouseUp]) + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + isResizing.current = true + startX.current = e.clientX + startWidth.current = rightSidebarWidth + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, + [rightSidebarWidth] + ) + + return ( +
+ {/* Tab switcher header */} +
+ setRightSidebarTab('explorer')} + /> + setRightSidebarTab('source-control')} + /> +
+ + {/* Tab content */} +
+ {rightSidebarTab === 'explorer' ? : } +
+ + {/* Resize handle on LEFT side */} +
+
+ ) +} + +function TabButton({ + label, + active, + onClick +}: { + label: string + active: boolean + onClick: () => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts new file mode 100644 index 00000000000..7d15e3d4eeb --- /dev/null +++ b/src/renderer/src/lib/language-detect.ts @@ -0,0 +1,100 @@ +function extname(filePath: string): string { + const lastDot = filePath.lastIndexOf('.') + const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + if (lastDot <= lastSep) return '' + return filePath.slice(lastDot) +} + +const EXT_TO_LANGUAGE: Record = { + '.ts': 'typescript', + '.tsx': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.json': 'json', + '.jsonc': 'json', + '.md': 'markdown', + '.mdx': 'markdown', + '.css': 'css', + '.scss': 'scss', + '.less': 'less', + '.html': 'html', + '.htm': 'html', + '.xml': 'xml', + '.svg': 'xml', + '.py': 'python', + '.rs': 'rust', + '.go': 'go', + '.java': 'java', + '.kt': 'kotlin', + '.kts': 'kotlin', + '.c': 'c', + '.h': 'c', + '.cpp': 'cpp', + '.cc': 'cpp', + '.cxx': 'cpp', + '.hpp': 'cpp', + '.cs': 'csharp', + '.rb': 'ruby', + '.php': 'php', + '.swift': 'swift', + '.sh': 'shell', + '.bash': 'shell', + '.zsh': 'shell', + '.fish': 'shell', + '.ps1': 'powershell', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'ini', + '.ini': 'ini', + '.cfg': 'ini', + '.conf': 'ini', + '.sql': 'sql', + '.graphql': 'graphql', + '.gql': 'graphql', + '.dockerfile': 'dockerfile', + '.proto': 'protobuf', + '.lua': 'lua', + '.r': 'r', + '.R': 'r', + '.scala': 'scala', + '.dart': 'dart', + '.ex': 'elixir', + '.exs': 'elixir', + '.erl': 'erlang', + '.hrl': 'erlang', + '.hs': 'haskell', + '.clj': 'clojure', + '.vue': 'html', + '.svelte': 'html', + '.tf': 'hcl', + '.hcl': 'hcl', + '.prisma': 'graphql' +} + +const FILENAME_TO_LANGUAGE: Record = { + Dockerfile: 'dockerfile', + Makefile: 'makefile', + 'CMakeLists.txt': 'cmake', + '.gitignore': 'ini', + '.gitattributes': 'ini', + '.editorconfig': 'ini', + '.env': 'ini', + '.env.local': 'ini', + '.env.development': 'ini', + '.env.production': 'ini' +} + +export function detectLanguage(filePath: string): string { + // Check exact filename first + const parts = filePath.split('/') + const filename = parts[parts.length - 1] + if (FILENAME_TO_LANGUAGE[filename]) { + return FILENAME_TO_LANGUAGE[filename] + } + + // Check extension + const ext = extname(filename).toLowerCase() + return EXT_TO_LANGUAGE[ext] ?? 'plaintext' +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts new file mode 100644 index 00000000000..094aec23a89 --- /dev/null +++ b/src/renderer/src/lib/monaco-setup.ts @@ -0,0 +1,8 @@ +import { loader } from '@monaco-editor/react' +import * as monaco from 'monaco-editor' + +// Configure Monaco to use the locally bundled editor instead of CDN +loader.config({ monaco }) + +// Re-export for convenience +export { monaco } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 37e753b2a17..80588b968f9 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -6,6 +6,7 @@ import { createTerminalSlice } from './slices/terminals' import { createUISlice } from './slices/ui' import { createSettingsSlice } from './slices/settings' import { createGitHubSlice } from './slices/github' +import { createEditorSlice } from './slices/editor' export const useAppStore = create()((...a) => ({ ...createRepoSlice(...a), @@ -13,7 +14,8 @@ export const useAppStore = create()((...a) => ({ ...createTerminalSlice(...a), ...createUISlice(...a), ...createSettingsSlice(...a), - ...createGitHubSlice(...a) + ...createGitHubSlice(...a), + ...createEditorSlice(...a) })) export type { AppState } from './types' diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts new file mode 100644 index 00000000000..2e8e8e5e59a --- /dev/null +++ b/src/renderer/src/store/slices/editor.ts @@ -0,0 +1,195 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitStatusEntry } from '../../../../shared/types' + +export interface OpenFile { + id: string // use filePath as unique key + filePath: string // absolute path + relativePath: string // relative to worktree root + worktreeId: string + language: string + isDirty: boolean + mode: 'edit' | 'diff' + diffStaged?: boolean +} + +export type RightSidebarTab = 'explorer' | 'source-control' + +export interface EditorSlice { + // Right sidebar + rightSidebarOpen: boolean + rightSidebarWidth: number + rightSidebarTab: RightSidebarTab + toggleRightSidebar: () => void + setRightSidebarOpen: (open: boolean) => void + setRightSidebarWidth: (width: number) => void + setRightSidebarTab: (tab: RightSidebarTab) => void + + // File explorer state + expandedDirs: Record> // worktreeId -> set of expanded dir paths + toggleDir: (worktreeId: string, dirPath: string) => void + + // Open files / editor tabs + openFiles: OpenFile[] + activeFileId: string | null + activeTabType: 'terminal' | 'editor' + setActiveTabType: (type: 'terminal' | 'editor') => void + openFile: (file: Omit) => void + closeFile: (fileId: string) => void + closeAllFiles: () => void + setActiveFile: (fileId: string) => void + markFileDirty: (fileId: string, dirty: boolean) => void + openDiff: ( + worktreeId: string, + filePath: string, + relativePath: string, + language: string, + staged: boolean + ) => void + openAllDiffs: (worktreeId: string, worktreePath: string) => void + + // Git status cache + gitStatusByWorktree: Record + setGitStatus: (worktreeId: string, entries: GitStatusEntry[]) => void +} + +export const createEditorSlice: StateCreator = (set) => ({ + // Right sidebar + rightSidebarOpen: false, + rightSidebarWidth: 280, + rightSidebarTab: 'explorer', + toggleRightSidebar: () => set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen })), + setRightSidebarOpen: (open) => set({ rightSidebarOpen: open }), + setRightSidebarWidth: (width) => set({ rightSidebarWidth: width }), + setRightSidebarTab: (tab) => set({ rightSidebarTab: tab }), + + // File explorer + expandedDirs: {}, + toggleDir: (worktreeId, dirPath) => + set((s) => { + const current = s.expandedDirs[worktreeId] ?? new Set() + const next = new Set(current) + if (next.has(dirPath)) { + next.delete(dirPath) + } else { + next.add(dirPath) + } + return { expandedDirs: { ...s.expandedDirs, [worktreeId]: next } } + }), + + // Open files + openFiles: [], + activeFileId: null, + activeTabType: 'terminal', + setActiveTabType: (type) => set({ activeTabType: type }), + + openFile: (file) => + set((s) => { + const id = file.filePath + const existing = s.openFiles.find((f) => f.id === id) + if (existing) { + // If it's already open, just activate it (and update mode if needed) + if (existing.mode === file.mode && existing.diffStaged === file.diffStaged) { + return { activeFileId: id, activeTabType: 'editor' } + } + // Update the existing file entry + return { + openFiles: s.openFiles.map((f) => + f.id === id ? { ...f, mode: file.mode, diffStaged: file.diffStaged } : f + ), + activeFileId: id, + activeTabType: 'editor' + } + } + return { + openFiles: [...s.openFiles, { ...file, id, isDirty: false }], + activeFileId: id, + activeTabType: 'editor' + } + }), + + closeFile: (fileId) => + set((s) => { + const idx = s.openFiles.findIndex((f) => f.id === fileId) + const newFiles = s.openFiles.filter((f) => f.id !== fileId) + let newActiveId = s.activeFileId + if (s.activeFileId === fileId) { + // Activate adjacent tab + if (newFiles.length === 0) { + newActiveId = null + } else if (idx >= newFiles.length) { + newActiveId = newFiles[newFiles.length - 1].id + } else { + newActiveId = newFiles[idx].id + } + } + // When last editor file is closed, switch back to terminal + const newActiveTabType = newFiles.length === 0 ? 'terminal' : s.activeTabType + return { openFiles: newFiles, activeFileId: newActiveId, activeTabType: newActiveTabType } + }), + + closeAllFiles: () => set({ openFiles: [], activeFileId: null, activeTabType: 'terminal' }), + + setActiveFile: (fileId) => set({ activeFileId: fileId }), + + markFileDirty: (fileId, dirty) => + set((s) => ({ + openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isDirty: dirty } : f)) + })), + + openDiff: (worktreeId, filePath, relativePath, language, staged) => + set((s) => { + // Use a unique ID that includes staging state to allow both staged and unstaged diffs + const id = `${filePath}${staged ? '::staged' : ''}` + const existing = s.openFiles.find((f) => f.id === id) + if (existing) { + return { activeFileId: id, activeTabType: 'editor' } + } + const newFile: OpenFile = { + id, + filePath, + relativePath, + worktreeId, + language, + isDirty: false, + mode: 'diff', + diffStaged: staged + } + return { + openFiles: [...s.openFiles, newFile], + activeFileId: id, + activeTabType: 'editor' + } + }), + + openAllDiffs: (worktreeId, worktreePath) => + set((s) => { + const id = `${worktreeId}::all-diffs` + const existing = s.openFiles.find((f) => f.id === id) + if (existing) { + return { activeFileId: id, activeTabType: 'editor' } + } + const newFile: OpenFile = { + id, + filePath: worktreePath, + relativePath: 'All Changes', + worktreeId, + language: 'plaintext', + isDirty: false, + mode: 'diff', + diffStaged: undefined + } + return { + openFiles: [...s.openFiles, newFile], + activeFileId: id, + activeTabType: 'editor' + } + }), + + // Git status + gitStatusByWorktree: {}, + setGitStatus: (worktreeId, entries) => + set((s) => ({ + gitStatusByWorktree: { ...s.gitStatusByWorktree, [worktreeId]: entries } + })) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index b4f605f8d05..f2f36b49e7a 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -71,6 +71,7 @@ export const createUISlice: StateCreator = (set) => ( hydratePersistedUI: (ui) => set({ sidebarWidth: ui.sidebarWidth, + rightSidebarWidth: ui.rightSidebarWidth ?? 280, groupBy: ui.groupBy, sortBy: ui.sortBy, persistedUIReady: true diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index daf6a2db43e..9b319a3207f 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -4,10 +4,12 @@ import type { TerminalSlice } from './slices/terminals' import type { UISlice } from './slices/ui' import type { SettingsSlice } from './slices/settings' import type { GitHubSlice } from './slices/github' +import type { EditorSlice } from './slices/editor' export type AppState = RepoSlice & WorktreeSlice & TerminalSlice & UISlice & SettingsSlice & - GitHubSlice + GitHubSlice & + EditorSlice diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9c0328695fc..7e5bfa1187e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -70,6 +70,7 @@ export function getDefaultUIState(): PersistedUIState { lastActiveRepoId: null, lastActiveWorktreeId: null, sidebarWidth: 280, + rightSidebarWidth: 350, groupBy: 'none', sortBy: 'name', uiZoomLevel: 0 diff --git a/src/shared/types.ts b/src/shared/types.ts index dac711d2d4b..51ea67e5d50 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -157,6 +157,7 @@ export interface PersistedUIState { lastActiveRepoId: string | null lastActiveWorktreeId: string | null sidebarWidth: number + rightSidebarWidth: number groupBy: 'none' | 'repo' | 'pr-status' sortBy: 'name' | 'recent' | 'repo' uiZoomLevel: number @@ -175,3 +176,26 @@ export interface PersistedState { } workspaceSession: WorkspaceSessionState } + +// ─── Filesystem ───────────────────────────────────────────── +export interface DirEntry { + name: string + isDirectory: boolean + isSymlink: boolean +} + +// ─── Git Status ───────────────────────────────────────────── +export type GitFileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied' +export type GitStagingArea = 'staged' | 'unstaged' | 'untracked' + +export interface GitStatusEntry { + path: string + status: GitFileStatus + area: GitStagingArea + oldPath?: string +} + +export interface GitDiffResult { + originalContent: string + modifiedContent: string +}