mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
1f34ab9337
commit
c0eaf328b2
27 changed files with 2472 additions and 45 deletions
|
|
@ -13,6 +13,9 @@ export default defineConfig({
|
|||
'@': resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [react(), tailwindcss()]
|
||||
plugins: [react(), tailwindcss()],
|
||||
worker: {
|
||||
format: 'es'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
168
src/main/git/status.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
|
|
@ -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
165
src/main/ipc/filesystem.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
28
src/preload/index.d.ts
vendored
28
src/preload/index.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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't Save
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleSaveDialogSave}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
209
src/renderer/src/components/editor/CombinedDiffViewer.tsx
Normal file
209
src/renderer/src/components/editor/CombinedDiffViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
src/renderer/src/components/editor/DiffViewer.tsx
Normal file
69
src/renderer/src/components/editor/DiffViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
src/renderer/src/components/editor/EditorPanel.tsx
Normal file
220
src/renderer/src/components/editor/EditorPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
src/renderer/src/components/editor/MonacoEditor.tsx
Normal file
86
src/renderer/src/components/editor/MonacoEditor.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
230
src/renderer/src/components/right-sidebar/FileExplorer.tsx
Normal file
230
src/renderer/src/components/right-sidebar/FileExplorer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
317
src/renderer/src/components/right-sidebar/SourceControl.tsx
Normal file
317
src/renderer/src/components/right-sidebar/SourceControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/renderer/src/components/right-sidebar/index.tsx
Normal file
116
src/renderer/src/components/right-sidebar/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
src/renderer/src/lib/language-detect.ts
Normal file
100
src/renderer/src/lib/language-detect.ts
Normal 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'
|
||||
}
|
||||
8
src/renderer/src/lib/monaco-setup.ts
Normal file
8
src/renderer/src/lib/monaco-setup.ts
Normal 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 }
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
195
src/renderer/src/store/slices/editor.ts
Normal file
195
src/renderer/src/store/slices/editor.ts
Normal 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 }
|
||||
}))
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export function getDefaultUIState(): PersistedUIState {
|
|||
lastActiveRepoId: null,
|
||||
lastActiveWorktreeId: null,
|
||||
sidebarWidth: 280,
|
||||
rightSidebarWidth: 350,
|
||||
groupBy: 'none',
|
||||
sortBy: 'name',
|
||||
uiZoomLevel: 0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue