Add right sidebar with file explorer, source control, and Monaco editor (#21)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neil 2026-03-22 11:47:22 -07:00 committed by GitHub
parent 1f34ab9337
commit c0eaf328b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2472 additions and 45 deletions

View file

@ -13,6 +13,9 @@ export default defineConfig({
'@': resolve('src/renderer/src')
}
},
plugins: [react(), tailwindcss()]
plugins: [react(), tailwindcss()],
worker: {
format: 'es'
}
}
})

View file

@ -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",

View file

@ -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: {}

168
src/main/git/status.ts Normal file
View file

@ -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<GitStatusEntry[]> {
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<GitDiffResult> {
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<void> {
await execFileAsync('git', ['add', '--', filePath], {
cwd: worktreePath,
encoding: 'utf-8'
})
}
/**
* Unstage a file.
*/
export async function unstageFile(worktreePath: string, filePath: string): Promise<void> {
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<void> {
await execFileAsync('git', ['checkout', '--', filePath], {
cwd: worktreePath,
encoding: 'utf-8'
})
}

View file

@ -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)

165
src/main/ipc/filesystem.ts Normal file
View file

@ -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<DirEntry[]> => {
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<void> => {
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<GitStatusEntry[]> => {
return getStatus(args.worktreePath)
}
)
ipcMain.handle(
'git:diff',
async (
_event,
args: { worktreePath: string; filePath: string; staged: boolean }
): Promise<GitDiffResult> => {
return getDiff(args.worktreePath, args.filePath, args.staged)
}
)
ipcMain.handle(
'git:stage',
async (_event, args: { worktreePath: string; filePath: string }): Promise<void> => {
await stageFile(args.worktreePath, args.filePath)
}
)
ipcMain.handle(
'git:unstage',
async (_event, args: { worktreePath: string; filePath: string }): Promise<void> => {
await unstageFile(args.worktreePath, args.filePath)
}
)
ipcMain.handle(
'git:discard',
async (_event, args: { worktreePath: string; filePath: string }): Promise<void> => {
await discardChanges(args.worktreePath, args.filePath)
}
)
}

View file

@ -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<DirEntry[]>
readFile: (args: { filePath: string }) => Promise<{ content: string; isBinary: boolean }>
writeFile: (args: { filePath: string; content: string }) => Promise<void>
stat: (args: {
filePath: string
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
}
interface GitApi {
status: (args: { worktreePath: string }) => Promise<GitStatusEntry[]>
diff: (args: {
worktreePath: string
filePath: string
staged: boolean
}) => Promise<GitDiffResult>
stage: (args: { worktreePath: string; filePath: string }) => Promise<void>
unstage: (args: { worktreePath: string; filePath: string }) => Promise<void>
discard: (args: { worktreePath: string; filePath: string }) => Promise<void>
}
interface Api {
repos: ReposApi
worktrees: WorktreesApi
@ -112,6 +136,8 @@ interface Api {
cache: CacheApi
session: SessionApi
updater: UpdaterApi
fs: FsApi
git: GitApi
ui: UIApi
}

View file

@ -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<void> =>
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<unknown[]> =>
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<void> =>
ipcRenderer.invoke('git:stage', args),
unstage: (args: { worktreePath: string; filePath: string }): Promise<void> =>
ipcRenderer.invoke('git:unstage', args),
discard: (args: { worktreePath: string; filePath: string }): Promise<void> =>
ipcRenderer.invoke('git:discard', args)
},
ui: {
get: (): Promise<unknown> => ipcRenderer.invoke('ui:get'),
set: (args: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('ui:set', args),

View file

@ -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 (
<div className="flex flex-col h-screen w-screen overflow-hidden">
@ -216,6 +246,16 @@ function App(): React.JSX.Element {
<Minimize2 size={14} />
</button>
)}
<button
className="sidebar-toggle"
onClick={toggleRightSidebar}
title="Toggle right sidebar"
aria-label="Toggle right sidebar"
disabled={!showSidebar}
style={{ marginRight: 12 }}
>
<PanelRight size={16} />
</button>
</div>
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{showSidebar ? <Sidebar /> : null}
@ -233,6 +273,7 @@ function App(): React.JSX.Element {
)}
{activeView === 'settings' ? <Settings /> : !activeWorktreeId ? <Landing /> : null}
</div>
{showSidebar && rightSidebarOpen ? <RightSidebar /> : null}
</div>
<Toaster
theme="system"

View file

@ -177,6 +177,34 @@
scrollbar-color: var(--muted-foreground, #737373) transparent;
}
/* ── Editor-style scrollbar (matches Monaco) ────────── */
.scrollbar-editor {
scrollbar-width: thin;
scrollbar-color: rgba(121, 121, 121, 0.4) transparent;
}
.scrollbar-editor::-webkit-scrollbar {
width: 14px;
}
.scrollbar-editor::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-editor::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.4);
border: 3px solid transparent;
border-radius: 7px;
background-clip: padding-box;
}
.scrollbar-editor::-webkit-scrollbar-thumb:hover {
background: rgba(121, 121, 121, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
}
/* Hide tab-strip scrollbars to prevent drag-time scrollbar flashes */
.terminal-tab-strip {
-ms-overflow-style: none;

View file

@ -14,7 +14,15 @@ import {
arrayMove
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { X, Plus, Terminal as TerminalIcon, Minimize2 } from 'lucide-react'
import {
X,
Plus,
Terminal as TerminalIcon,
Minimize2,
FileCode,
GitCompareArrows,
Copy
} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@ -33,6 +41,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { TerminalTab } from '../../../shared/types'
import type { OpenFile } from '../store/slices/editor'
interface SortableTabProps {
tab: TerminalTab
@ -289,6 +298,121 @@ function SortableTab({
)
}
function EditorFileTab({
file,
isActive,
editorFileCount,
onActivate,
onClose,
onCloseOthers,
onCloseAll
}: {
file: OpenFile
isActive: boolean
editorFileCount: number
onActivate: () => 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 (
<>
<div
onContextMenuCapture={(event) => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
setMenuPoint({ x: event.clientX, y: event.clientY })
setMenuOpen(true)
}}
>
<div
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
isActive
? 'bg-background text-foreground border-b-transparent'
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
onClick={onActivate}
onMouseDown={(e) => {
if (e.button === 1) {
e.preventDefault()
e.stopPropagation()
onClose()
}
}}
>
{isDiff ? (
<GitCompareArrows className="w-3.5 h-3.5 mr-1.5 shrink-0 text-muted-foreground" />
) : (
<FileCode className="w-3.5 h-3.5 mr-1.5 shrink-0 text-muted-foreground" />
)}
{file.isDirty && (
<span className="mr-1 size-1.5 rounded-full bg-foreground/60 shrink-0" />
)}
<span className="truncate max-w-[130px] mr-1.5">
{isDiff
? file.relativePath === 'All Changes'
? 'All Changes'
: `${fileName} (diff${file.diffStaged ? ' staged' : ''})`
: fileName}
</span>
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" sideOffset={0} align="start">
<DropdownMenuItem onSelect={onClose}>Close</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseOthers} disabled={editorFileCount <= 1}>
Close Others
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(file.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
interface TabBarProps {
tabs: TerminalTab[]
activeTabId: string | null
@ -303,6 +427,12 @@ interface TabBarProps {
onSetCustomTitle: (tabId: string, title: string | null) => void
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePaneExpand: (tabId: string) => void
editorFiles?: OpenFile[]
activeFileId?: string | null
activeTabType?: 'terminal' | 'editor'
onActivateFile?: (fileId: string) => void
onCloseFile?: (fileId: string) => void
onCloseAllFiles?: () => void
}
export default function TabBar({
@ -318,8 +448,15 @@ export default function TabBar({
onNewTab,
onSetCustomTitle,
onSetTabColor,
onTogglePaneExpand
onTogglePaneExpand,
editorFiles,
activeFileId,
activeTabType,
onActivateFile,
onCloseFile,
onCloseAllFiles: _onCloseAllFiles
}: TabBarProps): React.JSX.Element {
void _onCloseAllFiles
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 }
@ -343,18 +480,46 @@ export default function TabBar({
[tabIds, worktreeId, onReorder]
)
// Horizontal wheel scrolling for the tab strip
const tabStripRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = tabStripRef.current
if (!el) return
const onWheel = (e: WheelEvent): void => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault()
el.scrollLeft += e.deltaY
}
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const handleCloseOtherEditorFiles = useCallback(
(keepFileId: string) => {
if (!editorFiles || !onCloseFile) return
for (const f of editorFiles) {
if (f.id !== keepFileId) onCloseFile(f.id)
}
},
[editorFiles, onCloseFile]
)
return (
<div className="flex items-stretch h-9 bg-card border-b border-border overflow-hidden shrink-0">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<div className="terminal-tab-strip flex items-stretch overflow-x-auto overflow-y-hidden">
<div
ref={tabStripRef}
className="terminal-tab-strip flex items-stretch overflow-x-auto overflow-y-hidden"
>
{tabs.map((tab, index) => (
<SortableTab
key={tab.id}
tab={tab}
tabCount={tabs.length}
hasTabsToRight={index < tabs.length - 1}
isActive={tab.id === activeTabId}
isActive={activeTabType === 'terminal' && tab.id === activeTabId}
isExpanded={expandedPaneByTabId[tab.id] === true}
onActivate={onActivate}
onClose={onClose}
@ -365,6 +530,19 @@ export default function TabBar({
onToggleExpand={onTogglePaneExpand}
/>
))}
{/* Editor tabs - after terminal tabs */}
{editorFiles?.map((file) => (
<EditorFileTab
key={file.id}
file={file}
isActive={activeTabType === 'editor' && activeFileId === file.id}
editorFileCount={editorFiles.length}
onActivate={() => onActivateFile?.(file.id)}
onClose={() => onCloseFile?.(file.id)}
onCloseOthers={() => handleCloseOtherEditorFiles(file.id)}
onCloseAll={() => onCloseAllFiles?.()}
/>
))}
</div>
</SortableContext>
</DndContext>

View file

@ -1,9 +1,20 @@
import { useEffect, useCallback, useRef } from 'react'
import { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { useAppStore } from '../store'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import TabBar from './TabBar'
import TerminalPane from './TerminalPane'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
export default function Terminal(): React.JSX.Element | null {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeView = useAppStore((s) => s.activeView)
@ -20,10 +31,61 @@ export default function Terminal(): React.JSX.Element | null {
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const openFiles = useAppStore((s) => s.openFiles)
const activeFileId = useAppStore((s) => s.activeFileId)
const activeTabType = useAppStore((s) => s.activeTabType)
const setActiveTabType = useAppStore((s) => s.setActiveTabType)
const setActiveFile = useAppStore((s) => s.setActiveFile)
const closeFile = useAppStore((s) => s.closeFile)
const closeAllFiles = useAppStore((s) => s.closeAllFiles)
const markFileDirty = useAppStore((s) => s.markFileDirty)
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const allWorktrees = Object.values(worktreesByRepo).flat()
const prevTabCountRef = useRef(tabs.length)
// Save confirmation dialog state
const [saveDialogFileId, setSaveDialogFileId] = useState<string | null>(null)
const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null
const handleCloseFile = useCallback(
(fileId: string) => {
const file = useAppStore.getState().openFiles.find((f) => f.id === fileId)
if (file?.isDirty) {
setSaveDialogFileId(fileId)
return
}
closeFile(fileId)
},
[closeFile]
)
const handleSaveDialogSave = useCallback(async () => {
if (!saveDialogFileId) return
const file = useAppStore.getState().openFiles.find((f) => f.id === saveDialogFileId)
if (!file) return
// EditorPanel stores edit buffers internally — we need to read the current content from the editor.
// The simplest approach: dispatch a custom event that the MonacoEditor listens for to trigger save,
// then close. But that's complex. Instead, just save via the editor ref approach.
// Actually, we can read the current content from the DOM's Monaco instance.
// Simpler: just close without saving is "Don't Save", and save is handled by a custom event.
// For now, trigger a save event that EditorPanel listens for.
window.dispatchEvent(
new CustomEvent('orca:save-and-close', { detail: { fileId: saveDialogFileId } })
)
setSaveDialogFileId(null)
}, [saveDialogFileId])
const handleSaveDialogDiscard = useCallback(() => {
if (!saveDialogFileId) return
markFileDirty(saveDialogFileId, false)
closeFile(saveDialogFileId)
setSaveDialogFileId(null)
}, [saveDialogFileId, closeFile, markFileDirty])
const handleSaveDialogCancel = useCallback(() => {
setSaveDialogFileId(null)
}, [])
// Ensure activeTabId is valid (adjusting state during render)
if (tabs.length > 0 && (!activeTabId || !tabs.find((t) => t.id === activeTabId))) {
@ -62,19 +124,7 @@ export default function Terminal(): React.JSX.Element | null {
createTab(activeWorktreeId)
}, [workspaceSessionReady, activeWorktreeId, tabs.length, createTab])
// Animate tab bar height with grid transition
useEffect(() => {
const el = tabBarRef.current
if (!el) return
const showBar = tabs.length >= 2
if (showBar) {
el.style.gridTemplateRows = '1fr'
} else {
el.style.gridTemplateRows = '0fr'
}
prevTabCountRef.current = tabs.length
}, [tabs.length])
const totalTabs = tabs.length + openFiles.length
const handleNewTab = useCallback(() => {
if (!activeWorktreeId) return
@ -139,6 +189,14 @@ export default function Terminal(): React.JSX.Element | null {
[activeWorktreeId, closeTab]
)
const handleActivateTab = useCallback(
(tabId: string) => {
setActiveTab(tabId)
setActiveTabType('terminal')
},
[setActiveTab, setActiveTabType]
)
const handleTogglePaneExpand = useCallback(
(tabId: string) => {
setActiveTab(tabId)
@ -168,30 +226,60 @@ export default function Terminal(): React.JSX.Element | null {
// Cmd+W - close active tab
if (e.metaKey && e.key === 'w' && !e.shiftKey && !e.repeat) {
e.preventDefault()
const currentActiveTabId = useAppStore.getState().activeTabId
if (currentActiveTabId) {
handleCloseTab(currentActiveTabId)
const state = useAppStore.getState()
if (state.activeTabType === 'editor' && state.activeFileId) {
handleCloseFile(state.activeFileId)
} else if (state.activeTabId) {
handleCloseTab(state.activeTabId)
}
return
}
// Cmd+Shift+] and Cmd+Shift+[ - switch tabs
if (e.metaKey && e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) {
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
if (currentTabs.length > 1) {
const state = useAppStore.getState()
const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
const currentEditorFiles = state.openFiles
// Build unified tab list: terminal tabs then editor tabs
const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = [
...currentTerminalTabs.map((t) => ({ type: 'terminal' as const, id: t.id })),
...currentEditorFiles.map((f) => ({ type: 'editor' as const, id: f.id }))
]
if (allTabIds.length > 1) {
e.preventDefault()
const currentId = useAppStore.getState().activeTabId
const idx = currentTabs.findIndex((t) => t.id === currentId)
const currentId =
state.activeTabType === 'editor' ? state.activeFileId : state.activeTabId
const idx = allTabIds.findIndex((t) => t.id === currentId)
const dir = e.key === ']' ? 1 : -1
const next = currentTabs[(idx + dir + currentTabs.length) % currentTabs.length]
if (next) setActiveTab(next.id)
const next = allTabIds[(idx + dir + allTabIds.length) % allTabIds.length]
if (next.type === 'terminal') {
setActiveTab(next.id)
state.setActiveTabType('terminal')
} else {
state.setActiveFile(next.id)
state.setActiveTabType('editor')
}
}
return
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [activeWorktreeId, handleNewTab, handleCloseTab, setActiveTab])
}, [activeWorktreeId, handleNewTab, handleCloseTab, handleCloseFile, setActiveTab])
// Warn on window close if there are unsaved editor files
useEffect(() => {
const handler = (e: BeforeUnloadEvent): void => {
const dirtyFiles = useAppStore.getState().openFiles.filter((f) => f.isDirty)
if (dirtyFiles.length > 0) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [])
if (!activeWorktreeId) return null
@ -201,14 +289,14 @@ export default function Terminal(): React.JSX.Element | null {
<div
ref={tabBarRef}
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
style={{ gridTemplateRows: tabs.length >= 2 ? '1fr' : '0fr' }}
style={{ gridTemplateRows: totalTabs >= 2 ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<TabBar
tabs={tabs}
activeTabId={activeTabId}
worktreeId={activeWorktreeId}
onActivate={setActiveTab}
onActivate={handleActivateTab}
onClose={handleCloseTab}
onCloseOthers={handleCloseOthers}
onCloseToRight={handleCloseTabsToRight}
@ -218,12 +306,23 @@ export default function Terminal(): React.JSX.Element | null {
onSetTabColor={setTabColor}
expandedPaneByTabId={expandedPaneByTabId}
onTogglePaneExpand={handleTogglePaneExpand}
editorFiles={openFiles}
activeFileId={activeFileId}
activeTabType={activeTabType}
onActivateFile={(fileId) => {
setActiveFile(fileId)
setActiveTabType('editor')
}}
onCloseFile={handleCloseFile}
onCloseAllFiles={closeAllFiles}
/>
</div>
</div>
{/* Terminal panes container */}
<div className="relative flex-1 min-h-0 overflow-hidden">
{/* Terminal panes container - hidden when editor tab active */}
<div
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType === 'editor' && openFiles.length > 0 ? 'hidden' : ''}`}
>
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
@ -250,6 +349,49 @@ export default function Terminal(): React.JSX.Element | null {
)
})}
</div>
{/* Editor panel - shown when editor tab is active */}
{activeTabType === 'editor' && openFiles.length > 0 && (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
Loading editor...
</div>
}
>
<EditorPanel />
</Suspense>
)}
{/* Save confirmation dialog */}
<Dialog
open={saveDialogFileId !== null}
onOpenChange={(open) => {
if (!open) handleSaveDialogCancel()
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
<DialogDescription className="text-xs">
{saveDialogFile
? `"${saveDialogFile.relativePath.split('/').pop()}" has unsaved changes. Do you want to save before closing?`
: 'This file has unsaved changes.'}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogCancel}>
Cancel
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogDiscard}>
Don&apos;t Save
</Button>
<Button type="button" size="sm" onClick={handleSaveDialogSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -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<DiffSection[]>([])
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 (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No changes to display
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-background/50 shrink-0">
<span className="text-xs text-muted-foreground">{sections.length} changed files</span>
<div className="flex items-center gap-2">
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setSections((prev) => prev.map((s) => ({ ...s, collapsed: true })))}
>
Collapse All
</button>
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setSections((prev) => prev.map((s) => ({ ...s, collapsed: false })))}
>
Expand All
</button>
<button
className="px-2 py-0.5 text-xs rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setSideBySide((prev) => !prev)}
>
{sideBySide ? 'Inline' : 'Side by Side'}
</button>
</div>
</div>
{/* Scrollable diff sections */}
<div className="flex-1 overflow-auto scrollbar-editor">
{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 (
<div
key={section.entry.path + ':' + section.entry.area}
className="border-b border-border"
>
{/* Section header */}
<button
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm hover:bg-accent/30 transition-colors"
onClick={() => toggleSection(index)}
>
{section.collapsed ? (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="font-medium">{fileName}</span>
{dirPath && <span className="text-muted-foreground text-xs">{dirPath}</span>}
<span
className={cn(
'text-xs font-bold ml-auto',
section.entry.status === 'modified' && 'text-amber-500',
section.entry.status === 'added' && 'text-green-500',
section.entry.status === 'deleted' && 'text-red-500'
)}
>
{section.entry.area === 'staged' ? 'Staged' : 'Modified'}
</span>
</button>
{/* Diff content */}
{!section.collapsed && (
<div
style={{
height: Math.min(
400,
Math.max(150, (section.modifiedContent.split('\n').length + 2) * 19)
)
}}
>
{section.loading ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
Loading...
</div>
) : (
<DiffEditor
height="100%"
language={language}
original={section.originalContent}
modified={section.modifiedContent}
theme={isDark ? 'vs-dark' : 'vs'}
options={{
readOnly: true,
renderSideBySide: sideBySide,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: settings?.terminalFontSize ?? 13,
fontFamily: settings?.terminalFontFamily || 'monospace',
lineNumbers: 'on',
automaticLayout: true,
renderOverviewRuler: false,
scrollbar: { vertical: 'hidden' }
}}
/>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View file

@ -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 (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-background/50">
<span className="text-xs text-muted-foreground truncate">{filePath}</span>
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setSideBySide((prev) => !prev)}
title={sideBySide ? 'Switch to inline diff' : 'Switch to side-by-side diff'}
>
{sideBySide ? <Rows2 size={14} /> : <Columns2 size={14} />}
</button>
</div>
{/* Diff Editor */}
<div className="flex-1 min-h-0">
<DiffEditor
height="100%"
language={language}
original={originalContent}
modified={modifiedContent}
theme={isDark ? 'vs-dark' : 'vs'}
onMount={handleMount}
options={{
readOnly: true,
renderSideBySide: sideBySide,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: settings?.terminalFontSize ?? 13,
fontFamily: settings?.terminalFontFamily || 'monospace',
lineNumbers: 'on',
automaticLayout: true,
renderOverviewRuler: true,
padding: { top: 8 }
}}
/>
</div>
</div>
)
}

View file

@ -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<Record<string, FileContent>>({})
const [diffContents, setDiffContents] = useState<Record<string, DiffContent>>({})
const [editBuffers, setEditBuffers] = useState<Record<string, string>>({})
// 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<void> => {
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<void> => {
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<void> => {
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<string, FileContent> = {}
for (const [k, v] of Object.entries(prev)) {
if (openIds.has(k)) next[k] = v
}
return next
})
setDiffContents((prev) => {
const next: Record<string, DiffContent> = {}
for (const [k, v] of Object.entries(prev)) {
if (openIds.has(k)) next[k] = v
}
return next
})
setEditBuffers((prev) => {
const next: Record<string, string> = {}
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 = (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading editor...
</div>
)
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<Suspense fallback={loadingFallback}>
{isCombinedDiff ? (
<CombinedDiffViewer worktreePath={activeFile.filePath} />
) : activeFile.mode === 'edit' ? (
(() => {
const fc = fileContents[activeFile.id]
if (!fc) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
</div>
)
}
if (fc.isBinary) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Binary file cannot display
</div>
)
}
return (
<MonacoEditor
filePath={activeFile.filePath}
content={editBuffers[activeFile.id] ?? fc.content}
language={activeFile.language}
onContentChange={handleContentChange}
onSave={handleSave}
/>
)
})()
) : (
(() => {
const dc = diffContents[activeFile.id]
if (!dc) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading diff...
</div>
)
}
return (
<DiffViewer
originalContent={dc.originalContent}
modifiedContent={dc.modifiedContent}
language={activeFile.language}
filePath={activeFile.relativePath}
/>
)
})()
)}
</Suspense>
</div>
)
}

View file

@ -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<editor.IStandaloneCodeEditor | null>(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 (
<Editor
height="100%"
language={language}
value={content}
theme={isDark ? 'vs-dark' : 'vs'}
onChange={handleChange}
onMount={handleMount}
options={{
minimap: { enabled: true },
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: settings?.terminalFontSize ?? 13,
fontFamily: settings?.terminalFontFamily || 'monospace',
lineNumbers: 'on',
renderLineHighlight: 'line',
automaticLayout: true,
tabSize: 2,
smoothScrolling: true,
cursorSmoothCaretAnimation: 'off',
padding: { top: 8 }
}}
path={filePath}
/>
)
}

View file

@ -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<Record<string, DirCache>>({})
const scrollRef = useRef<HTMLDivElement>(null)
const expanded = useMemo(
() =>
activeWorktreeId ? (expandedDirs[activeWorktreeId] ?? new Set<string>()) : new Set<string>(),
[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 (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground px-4 text-center">
Select a worktree to browse files
</div>
)
}
if (flatRows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
)
}
return (
<div ref={scrollRef} className="flex-1 overflow-auto scrollbar-sleek">
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{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 (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
<button
className={cn(
'flex items-center w-full h-[26px] px-2 gap-1 text-left text-[12px] transition-colors hover:bg-accent/60 rounded-sm',
isActive && !node.isDirectory && 'bg-accent text-accent-foreground'
)}
style={{ paddingLeft: `${node.depth * 16 + 8}px` }}
onClick={() => handleClick(node)}
>
{node.isDirectory ? (
<>
<ChevronRight
className={cn(
'size-3 shrink-0 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
{isLoading ? (
<Loader2 className="size-3.5 shrink-0 text-muted-foreground animate-spin" />
) : isExpanded ? (
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
)}
</>
) : (
<>
<span className="size-3 shrink-0" />
<File className="size-3.5 shrink-0 text-muted-foreground" />
</>
)}
<span className="truncate">{node.name}</span>
</button>
</div>
)
})}
</div>
</div>
)
}

View file

@ -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<string, React.ComponentType<{ className?: string }>> = {
modified: FileEdit,
added: FilePlus,
deleted: FileMinus,
renamed: ArrowRightLeft,
untracked: FileQuestion,
copied: FilePlus
}
const STATUS_LABELS: Record<string, string> = {
modified: 'M',
added: 'A',
deleted: 'D',
renamed: 'R',
untracked: 'U',
copied: 'C'
}
const STATUS_COLORS: Record<string, string> = {
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<GitStagingArea, string> = {
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<Set<string>>(new Set())
const pollRef = useRef<ReturnType<typeof setInterval> | 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<GitStagingArea, GitStatusEntry[]> = {
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 (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground px-4 text-center">
Select a worktree to view changes
</div>
)
}
if (entries.length === 0) {
return (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground">
No changes detected
</div>
)
}
return (
<div className="flex-1 overflow-auto scrollbar-sleek">
{/* View All Changes button */}
<button
className="flex items-center gap-1.5 w-full px-3 py-2 text-left text-[12px] font-medium text-foreground hover:bg-accent/40 transition-colors border-b border-border"
onClick={handleViewAllChanges}
>
<GitCompareArrows className="size-3.5 text-muted-foreground" />
View All Changes
<span className="text-[10px] font-medium bg-muted/60 rounded-full px-1.5 py-0.5 ml-auto text-muted-foreground">
{entries.length}
</span>
</button>
{SECTION_ORDER.map((area) => {
const items = grouped[area]
if (items.length === 0) return null
const isCollapsed = collapsedSections.has(area)
return (
<div key={area}>
{/* Section header */}
<button
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-accent/40 transition-colors"
onClick={() => toggleSection(area)}
>
<ChevronDown
className={cn('size-3 transition-transform', isCollapsed && '-rotate-90')}
/>
<span className="flex-1">{SECTION_LABELS[area]}</span>
<span className="text-[10px] font-medium bg-muted/60 rounded-full px-1.5 py-0.5">
{items.length}
</span>
</button>
{/* 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 (
<div
key={`${area}:${entry.path}`}
className="group flex items-center gap-1 px-3 py-0.5 hover:bg-accent/40 transition-colors cursor-pointer"
onClick={() => handleOpenDiff(entry)}
>
<StatusIcon className={cn('size-3.5 shrink-0', STATUS_COLORS[entry.status])} />
<span className="truncate text-[12px] flex-1 min-w-0">
<span className="text-foreground">{fileName}</span>
{dirPath && (
<span className="text-muted-foreground ml-1.5 text-[11px]">{dirPath}</span>
)}
</span>
<span
className={cn(
'text-[10px] font-bold shrink-0 w-4 text-center',
STATUS_COLORS[entry.status]
)}
>
{STATUS_LABELS[entry.status]}
</span>
{/* Action buttons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{area === 'unstaged' || area === 'untracked' ? (
<>
{area === 'unstaged' && (
<ActionButton
icon={Undo2}
title="Discard changes"
onClick={(e) => {
e.stopPropagation()
void handleDiscard(entry.path)
}}
/>
)}
<ActionButton
icon={Plus}
title="Stage"
onClick={(e) => {
e.stopPropagation()
void handleStage(entry.path)
}}
/>
</>
) : (
<ActionButton
icon={Minus}
title="Unstage"
onClick={(e) => {
e.stopPropagation()
void handleUnstage(entry.path)
}}
/>
)}
</div>
</div>
)
})}
</div>
)
})}
</div>
)
}
function ActionButton({
icon: Icon,
title,
onClick
}: {
icon: React.ComponentType<{ className?: string }>
title: string
onClick: (e: React.MouseEvent) => void
}): React.JSX.Element {
return (
<button
className="p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title={title}
onClick={onClick}
>
<Icon className="size-3" />
</button>
)
}

View file

@ -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 (
<div
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden transition-[width] duration-200"
style={{
width: rightSidebarOpen ? rightSidebarWidth : 0,
borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
>
{/* Tab switcher header */}
<div className="flex items-center border-b border-border h-[33px] min-h-[33px]">
<TabButton
label="Explorer"
active={rightSidebarTab === 'explorer'}
onClick={() => setRightSidebarTab('explorer')}
/>
<TabButton
label="Source Control"
active={rightSidebarTab === 'source-control'}
onClick={() => setRightSidebarTab('source-control')}
/>
</div>
{/* Tab content */}
<div className="flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
{rightSidebarTab === 'explorer' ? <FileExplorer /> : <SourceControl />}
</div>
{/* Resize handle on LEFT side */}
<div
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
onMouseDown={onResizeStart}
/>
</div>
)
}
function TabButton({
label,
active,
onClick
}: {
label: string
active: boolean
onClick: () => void
}): React.JSX.Element {
return (
<button
className={`flex-1 text-[11px] font-semibold uppercase tracking-wider py-2 px-3 transition-colors ${
active
? 'text-foreground border-b-2 border-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={onClick}
>
{label}
</button>
)
}

View file

@ -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<string, string> = {
'.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<string, string> = {
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'
}

View file

@ -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 }

View file

@ -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<AppState>()((...a) => ({
...createRepoSlice(...a),
@ -13,7 +14,8 @@ export const useAppStore = create<AppState>()((...a) => ({
...createTerminalSlice(...a),
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a)
...createGitHubSlice(...a),
...createEditorSlice(...a)
}))
export type { AppState } from './types'

View file

@ -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<string, Set<string>> // 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<OpenFile, 'id' | 'isDirty'>) => 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<string, GitStatusEntry[]>
setGitStatus: (worktreeId: string, entries: GitStatusEntry[]) => void
}
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<string>()
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 }
}))
})

View file

@ -71,6 +71,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
hydratePersistedUI: (ui) =>
set({
sidebarWidth: ui.sidebarWidth,
rightSidebarWidth: ui.rightSidebarWidth ?? 280,
groupBy: ui.groupBy,
sortBy: ui.sortBy,
persistedUIReady: true

View file

@ -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

View file

@ -70,6 +70,7 @@ export function getDefaultUIState(): PersistedUIState {
lastActiveRepoId: null,
lastActiveWorktreeId: null,
sidebarWidth: 280,
rightSidebarWidth: 350,
groupBy: 'none',
sortBy: 'name',
uiZoomLevel: 0

View file

@ -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
}