diff --git a/.gitignore b/.gitignore index 9d9bd4bfe..1e182e540 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Build artifacts +tsconfig.*.tsbuildinfo + # Dependencies node_modules/ diff --git a/package.json b/package.json index f9879e8a1..f61813a60 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,10 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", - "electron-updater": "^6.3.9" + "electron-updater": "^6.3.9", + "node-pty": "^1.1.0", + "restty": "^0.1.34", + "zustand": "^5.0.12" }, "devDependencies": { "@electron-toolkit/tsconfig": "^2.0.0", @@ -56,7 +59,8 @@ "pnpm": { "onlyBuiltDependencies": [ "electron", - "esbuild" + "esbuild", + "node-pty" ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dc8f9dc9..927de3b61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,15 @@ importers: electron-updater: specifier: ^6.3.9 version: 6.8.3 + node-pty: + specifier: ^1.1.0 + version: 1.1.0 + restty: + specifier: ^0.1.34 + version: 0.1.34(typescript@5.9.3) + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.14)(react@19.2.4) devDependencies: '@electron-toolkit/tsconfig': specifier: ^2.0.0 @@ -1869,6 +1878,9 @@ packages: node-addon-api@1.7.2: resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -1877,6 +1889,9 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -2046,6 +2061,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + restty@0.1.34: + resolution: {integrity: sha512-466w3EXe/yLNfO8Jbha6tLLTij1QRI2MnKbm0UHCmP33Avon4YikIeIIPW1ga1A9310poAcnkoXPeV0b84Yyxw==} + engines: {bun: '>=1.2.0'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -2218,6 +2237,11 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} + text-shaper@0.1.18: + resolution: {integrity: sha512-ZfuqKPupphWz6OS3IhlEc5tPmJFmOJVqhp5Q4uQc32zrHwfu6uY/cIsv0i/tirUVSfVDohmVpuGr5lYOVyK13w==} + peerDependencies: + typescript: ^5 + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -2402,6 +2426,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: 7zip-bin@5.2.0: {} @@ -4171,6 +4213,8 @@ snapshots: node-addon-api@1.7.2: optional: true + node-addon-api@7.1.1: {} + node-api-version@0.2.1: dependencies: semver: 7.7.4 @@ -4190,6 +4234,10 @@ snapshots: transitivePeerDependencies: - supports-color + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + node-releases@2.0.36: {} nopt@8.1.0: @@ -4382,6 +4430,12 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + restty@0.1.34(typescript@5.9.3): + dependencies: + text-shaper: 0.1.18(typescript@5.9.3) + transitivePeerDependencies: + - typescript + retry@0.12.0: {} rfdc@1.4.1: {} @@ -4586,6 +4640,10 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 + text-shaper@0.1.18(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 @@ -4729,3 +4787,8 @@ snapshots: fd-slicer: 1.1.0 yocto-queue@0.1.0: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 diff --git a/resources/icon-dev.png b/resources/icon-dev.png new file mode 100644 index 000000000..ea440731b Binary files /dev/null and b/resources/icon-dev.png differ diff --git a/resources/icon.png b/resources/icon.png index cf9e8b2c8..fa6d0d558 100644 Binary files a/resources/icon.png and b/resources/icon.png differ diff --git a/src/main/index.ts b/src/main/index.ts index 06e06b4c5..17e60ffd4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,16 +1,35 @@ -import { app, shell, BrowserWindow, ipcMain } from 'electron' +import { app, shell, BrowserWindow, ipcMain, Menu, nativeImage } from 'electron' import { join } from 'path' +import { execSync } from 'child_process' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' +import devIcon from '../../resources/icon-dev.png?asset' +import * as pty from 'node-pty' -function createWindow(): void { - // Create the browser window. +// Enable WebGPU in Electron +app.commandLine.appendSwitch('enable-features', 'Vulkan,UseSkiaGraphite') +app.commandLine.appendSwitch('enable-unsafe-webgpu') + +// --------------------------------------------------------------------------- +// PTY instance tracking +// --------------------------------------------------------------------------- +let ptyCounter = 0 +const ptyProcesses = new Map() + +// --------------------------------------------------------------------------- +// Window creation +// --------------------------------------------------------------------------- +function createWindow(): BrowserWindow { const mainWindow = new BrowserWindow({ - width: 900, - height: 670, + width: 1200, + height: 800, + minWidth: 600, + minHeight: 400, show: false, autoHideMenuBar: true, - ...(process.platform === 'linux' ? { icon } : {}), + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : undefined, + ...(process.platform === 'darwin' ? { trafficLightPosition: { x: 16, y: 12 } } : {}), + icon: is.dev ? devIcon : icon, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false @@ -33,42 +52,230 @@ function createWindow(): void { } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } + + return mainWindow } -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. +// --------------------------------------------------------------------------- +// Worktree helpers +// --------------------------------------------------------------------------- +interface WorktreeInfo { + path: string + head: string + branch: string + isBare: boolean +} + +function parseWorktreeList(output: string): WorktreeInfo[] { + const worktrees: WorktreeInfo[] = [] + const blocks = output.trim().split('\n\n') + + for (const block of blocks) { + if (!block.trim()) continue + + const lines = block.trim().split('\n') + let path = '' + let head = '' + let branch = '' + let isBare = false + + for (const line of lines) { + if (line.startsWith('worktree ')) { + path = line.slice('worktree '.length) + } else if (line.startsWith('HEAD ')) { + head = line.slice('HEAD '.length) + } else if (line.startsWith('branch ')) { + branch = line.slice('branch '.length) + } else if (line === 'bare') { + isBare = true + } + } + + if (path) { + worktrees.push({ path, head, branch, isBare }) + } + } + + return worktrees +} + +// --------------------------------------------------------------------------- +// App lifecycle +// --------------------------------------------------------------------------- app.whenReady().then(() => { - // Set app user model id for windows electronApp.setAppUserModelId('com.electron') - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils + if (process.platform === 'darwin') { + const dockIcon = nativeImage.createFromPath(is.dev ? devIcon : icon) + app.dock.setIcon(dockIcon) + } + app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) - // IPC test - ipcMain.on('ping', () => console.log('pong')) + // Override default menu to prevent Cmd+W from closing the window. + // The renderer handles Cmd+W to close terminal panes instead. + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' } + ] + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] + }, + { + label: 'Window', + submenu: [{ role: 'minimize' }, { role: 'zoom' }] + } + ] + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) - createWindow() + const mainWindow = createWindow() + // ------------------------------------------------------------------------- + // PTY IPC handlers + // ------------------------------------------------------------------------- + ipcMain.handle('pty:spawn', (_event, args: { cols: number; rows: number; cwd?: string }) => { + const id = String(++ptyCounter) + const shell = process.env.SHELL || '/bin/zsh' + + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd: args.cwd || process.env.HOME || '/', + env: { + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor' + } as Record + }) + + ptyProcesses.set(id, ptyProcess) + + ptyProcess.onData((data) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:data', { id, data }) + } + }) + + ptyProcess.onExit(({ exitCode }) => { + ptyProcesses.delete(id) + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:exit', { id, code: exitCode }) + } + }) + + return { id } + }) + + ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => { + const proc = ptyProcesses.get(args.id) + if (proc) { + proc.write(args.data) + } + }) + + ipcMain.handle('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + const proc = ptyProcesses.get(args.id) + if (proc) { + proc.resize(args.cols, args.rows) + } + }) + + ipcMain.handle('pty:kill', (_event, args: { id: string }) => { + const proc = ptyProcesses.get(args.id) + if (proc) { + proc.kill() + ptyProcesses.delete(args.id) + } + }) + + // ------------------------------------------------------------------------- + // Worktree IPC handlers + // ------------------------------------------------------------------------- + ipcMain.handle('worktrees:list', (_event, args: { cwd: string }): WorktreeInfo[] => { + try { + const output = execSync('git worktree list --porcelain', { + cwd: args.cwd, + encoding: 'utf-8' + }) + return parseWorktreeList(output) + } catch { + return [] + } + }) + + ipcMain.handle('worktrees:get-current', (_event): WorktreeInfo[] => { + try { + const repoRoot = execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8' + }).trim() + + const output = execSync('git worktree list --porcelain', { + cwd: repoRoot, + encoding: 'utf-8' + }) + return parseWorktreeList(output) + } catch { + return [] + } + }) + + // ------------------------------------------------------------------------- + // macOS re-activate + // ------------------------------------------------------------------------- app.on('activate', function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- +app.on('before-quit', () => { + for (const [id, proc] of ptyProcesses) { + proc.kill() + ptyProcesses.delete(id) + } +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) - -// In this file you can include the rest of your app's specific main process -// code. You can also put them in separate files and require them here. diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a15366957..47da95943 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,8 +1,34 @@ import { ElectronAPI } from '@electron-toolkit/preload' +interface WorktreeInfo { + path: string + head: string + branch: string + isBare: boolean +} + +interface PtyApi { + spawn: (opts: { cols: number; rows: number; cwd?: string }) => Promise<{ id: string }> + write: (id: string, data: string) => void + resize: (id: string, cols: number, rows: number) => void + kill: (id: string) => Promise + onData: (callback: (data: { id: string; data: string }) => void) => () => void + onExit: (callback: (data: { id: string; code: number }) => void) => () => void +} + +interface WorktreesApi { + list: (cwd: string) => Promise + getCurrent: () => Promise +} + +interface Api { + pty: PtyApi + worktrees: WorktreesApi +} + declare global { interface Window { electron: ElectronAPI - api: unknown + api: Api } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2d1852475..3e3571ff1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,8 +1,48 @@ -import { contextBridge } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' // Custom APIs for renderer -const api = {} +const api = { + pty: { + spawn: (opts: { cols: number; rows: number; cwd?: string }): Promise<{ id: string }> => + ipcRenderer.invoke('pty:spawn', opts), + + write: (id: string, data: string): void => { + ipcRenderer.send('pty:write', { id, data }) + }, + + resize: (id: string, cols: number, rows: number): void => { + ipcRenderer.invoke('pty:resize', { id, cols, rows }) + }, + + kill: (id: string): Promise => ipcRenderer.invoke('pty:kill', { id }), + + onData: (callback: (data: { id: string; data: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => + callback(data) + ipcRenderer.on('pty:data', listener) + return () => ipcRenderer.removeListener('pty:data', listener) + }, + + onExit: (callback: (data: { id: string; code: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) => + callback(data) + ipcRenderer.on('pty:exit', listener) + return () => ipcRenderer.removeListener('pty:exit', listener) + } + }, + + worktrees: { + list: ( + cwd: string + ): Promise> => + ipcRenderer.invoke('worktrees:list', { cwd }), + + getCurrent: (): Promise< + Array<{ path: string; head: string; branch: string; isBare: boolean }> + > => ipcRenderer.invoke('worktrees:get-current') + } +} // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise diff --git a/src/renderer/index.html b/src/renderer/index.html index e198e05e4..b74c4d75d 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,11 +2,11 @@ - Electron + Orca diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 73de51c9e..fed553e4e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,34 +1,57 @@ -import Versions from './components/Versions' -import electronLogo from './assets/electron.svg' +import { useEffect } from 'react' +import { useAppStore } from './store' +import Sidebar from './components/Sidebar' +import Terminal from './components/Terminal' +import Landing from './components/Landing' function App(): React.JSX.Element { - const ipcHandle = (): void => window.electron.ipcRenderer.send('ping') + const toggleSidebar = useAppStore((s) => s.toggleSidebar) + const showTerminal = useAppStore((s) => s.showTerminal) + const setShowTerminal = useAppStore((s) => s.setShowTerminal) + const terminalTitle = useAppStore((s) => s.terminalTitle) + + // Enter key on landing page to spawn a new terminal + useEffect(() => { + if (showTerminal) return + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Enter' && !e.repeat) { + e.preventDefault() + setShowTerminal(true) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [showTerminal, setShowTerminal]) return ( - <> - logo -
Powered by electron-vite
-
- Build an Electron app with React -  and TypeScript -
-

- Please try pressing F12 to open the devTool -

-
- -
- - Send IPC - +
+
+
+ +
+ {showTerminal && terminalTitle ? terminalTitle : 'Orca'}
+
- - +
+ + {showTerminal ? : } +
+
) } diff --git a/src/renderer/src/assets/base.css b/src/renderer/src/assets/base.css index 5ed6406a3..8a41cc9ad 100644 --- a/src/renderer/src/assets/base.css +++ b/src/renderer/src/assets/base.css @@ -1,34 +1,12 @@ :root { - --ev-c-white: #ffffff; - --ev-c-white-soft: #f8f8f8; - --ev-c-white-mute: #f2f2f2; - - --ev-c-black: #1b1b1f; - --ev-c-black-soft: #222222; - --ev-c-black-mute: #282828; - - --ev-c-gray-1: #515c67; - --ev-c-gray-2: #414853; - --ev-c-gray-3: #32363f; - - --ev-c-text-1: rgba(255, 255, 245, 0.86); - --ev-c-text-2: rgba(235, 235, 245, 0.6); - --ev-c-text-3: rgba(235, 235, 245, 0.38); - - --ev-button-alt-border: transparent; - --ev-button-alt-text: var(--ev-c-text-1); - --ev-button-alt-bg: var(--ev-c-gray-3); - --ev-button-alt-hover-border: transparent; - --ev-button-alt-hover-text: var(--ev-c-text-1); - --ev-button-alt-hover-bg: var(--ev-c-gray-2); -} - -:root { - --color-background: var(--ev-c-black); - --color-background-soft: var(--ev-c-black-soft); - --color-background-mute: var(--ev-c-black-mute); - - --color-text: var(--ev-c-text-1); + --bg-primary: #181818; + --bg-sidebar: #1a1a1a; + --bg-titlebar: #1e1e1e; + --border-color: #2a2a2a; + --text-primary: rgba(255, 255, 245, 0.86); + --text-secondary: rgba(235, 235, 245, 0.6); + --text-muted: rgba(235, 235, 245, 0.38); + --accent: #7c93ee; } *, @@ -36,7 +14,7 @@ *::after { box-sizing: border-box; margin: 0; - font-weight: normal; + padding: 0; } ul { @@ -44,24 +22,13 @@ ul { } body { - min-height: 100vh; - color: var(--color-text); - background: var(--color-background); - line-height: 1.6; - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - Roboto, - Oxygen, - Ubuntu, - Cantarell, - 'Fira Sans', - 'Droid Sans', - 'Helvetica Neue', - sans-serif; - text-rendering: optimizeLegibility; + margin: 0; + padding: 0; + overflow: hidden; + height: 100vh; + background: var(--bg-primary); + color: var(--text-primary); + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/src/renderer/src/assets/electron.svg b/src/renderer/src/assets/electron.svg deleted file mode 100644 index 45ef09cf4..000000000 --- a/src/renderer/src/assets/electron.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 0179fc4c2..f1366b0bb 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1,171 +1,200 @@ @import './base.css'; -body { - display: flex; - align-items: center; - justify-content: center; +/* ── Layout ───────────────────────────────────────────── */ + +#root { + height: 100vh; + width: 100vw; overflow: hidden; - background-image: url('./wavy-lines.svg'); - background-size: cover; +} + +.app-layout { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +/* ── Titlebar ─────────────────────────────────────────── */ + +.titlebar { + height: 38px; + min-height: 38px; + display: flex; + flex-direction: row; + align-items: center; + background: var(--bg-titlebar); + border-bottom: 1px solid var(--border-color); + -webkit-app-region: drag; user-select: none; } -code { - font-weight: 600; - padding: 3px 5px; - border-radius: 2px; - background-color: var(--color-background-mute); - font-family: - ui-monospace, - SFMono-Regular, - SF Mono, - Menlo, - Consolas, - Liberation Mono, - monospace; - font-size: 85%; +.titlebar-traffic-light-pad { + width: 80px; + flex-shrink: 0; } -#root { +.sidebar-toggle { + -webkit-app-region: no-drag; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 8px; display: flex; align-items: center; justify-content: center; - flex-direction: column; - margin-bottom: 80px; + border-radius: 4px; + transition: background 150ms; } -.logo { - margin-bottom: 20px; - -webkit-user-drag: none; - height: 128px; - width: 128px; - will-change: filter; - transition: filter 300ms; +.sidebar-toggle:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); } -.logo:hover { - filter: drop-shadow(0 0 1.2em #6988e6aa); -} - -.creator { - font-size: 14px; - line-height: 16px; - color: var(--ev-c-text-2); - font-weight: 600; - margin-bottom: 10px; -} - -.text { - font-size: 28px; - color: var(--ev-c-text-1); - font-weight: 700; - line-height: 32px; - text-align: center; - margin: 0 10px; - padding: 16px 0; -} - -.tip { - font-size: 16px; - line-height: 24px; - color: var(--ev-c-text-2); - font-weight: 600; -} - -.react { - background: -webkit-linear-gradient(315deg, #087ea4 55%, #7c93ee); - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - font-weight: 700; -} - -.ts { - background: -webkit-linear-gradient(315deg, #3178c6 45%, #f0dc4e); - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - font-weight: 700; -} - -.actions { - display: flex; - padding-top: 32px; - margin: -6px; - flex-wrap: wrap; - justify-content: flex-start; -} - -.action { - flex-shrink: 0; - padding: 6px; -} - -.action a { - cursor: pointer; - text-decoration: none; - display: inline-block; - border: 1px solid transparent; - text-align: center; - font-weight: 600; +.titlebar-title { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-left: 8px; white-space: nowrap; - border-radius: 20px; - padding: 0 20px; - line-height: 38px; - font-size: 14px; - border-color: var(--ev-button-alt-border); - color: var(--ev-button-alt-text); - background-color: var(--ev-button-alt-bg); -} - -.action a:hover { - border-color: var(--ev-button-alt-hover-border); - color: var(--ev-button-alt-hover-text); - background-color: var(--ev-button-alt-hover-bg); -} - -.versions { - position: absolute; - bottom: 30px; - margin: 0 auto; - padding: 15px 0; - font-family: 'Menlo', 'Lucida Console', monospace; - display: inline-flex; overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} + +.titlebar-spacer { + width: 16px; + flex-shrink: 0; +} + +/* ── Content ──────────────────────────────────────────── */ + +.content-area { + display: flex; + flex-direction: row; + flex: 1; + overflow: hidden; +} + +/* ── Sidebar ──────────────────────────────────────────── */ + +.sidebar { + width: 240px; + flex-shrink: 0; + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + overflow-y: auto; + overflow-x: hidden; + transition: width 200ms ease; +} + +.sidebar.collapsed { + width: 0; + border-right: none; + overflow: hidden; +} + +.sidebar-header { + padding: 12px 16px 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.worktree-list { + padding: 0 4px; +} + +.worktree-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + transition: background 120ms; +} + +.worktree-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.worktree-item.active { + background: rgba(255, 255, 255, 0.08); +} + +.worktree-branch { + font-size: 13px; + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.worktree-path { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Terminal ─────────────────────────────────────────── */ + +.terminal-container { + flex: 1; + overflow: hidden; + position: relative; +} + +/* ── Landing ─────────────────────────────────────────── */ + +.landing { + flex: 1; + display: flex; align-items: center; - border-radius: 22px; - background-color: #202127; - backdrop-filter: blur(24px); + justify-content: center; + background: var(--bg-primary); } -.versions li { - display: block; - float: left; - border-right: 1px solid var(--ev-c-gray-1); - padding: 0 20px; +.landing-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.landing-title { + font-size: 48px; + font-weight: 700; + color: var(--text-secondary); + letter-spacing: -0.02em; +} + +.landing-action { + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: inherit; font-size: 14px; - line-height: 14px; - opacity: 0.8; - &:last-child { - border: none; - } + padding: 8px 24px; + border-radius: 6px; + cursor: pointer; + transition: background 150ms; } -@media (max-width: 720px) { - .text { - font-size: 20px; - } +.landing-action:hover { + background: rgba(255, 255, 255, 0.1); } -@media (max-width: 620px) { - .versions { - display: none; - } -} - -@media (max-width: 350px) { - .tip, - .actions { - display: none; - } +.landing-hint { + font-size: 12px; + color: var(--text-muted); } diff --git a/src/renderer/src/assets/wavy-lines.svg b/src/renderer/src/assets/wavy-lines.svg deleted file mode 100644 index d08c61199..000000000 --- a/src/renderer/src/assets/wavy-lines.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx new file mode 100644 index 000000000..3f41696ad --- /dev/null +++ b/src/renderer/src/components/Landing.tsx @@ -0,0 +1,17 @@ +import { useAppStore } from '../store' + +export default function Landing(): React.JSX.Element { + const setShowTerminal = useAppStore((s) => s.setShowTerminal) + + return ( +
+
+

Orca

+ + or press Enter +
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx new file mode 100644 index 000000000..6cd7cb0ed --- /dev/null +++ b/src/renderer/src/components/Sidebar.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react' +import { useAppStore } from '../store' + +function branchDisplayName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') +} + +function pathBasename(p: string): string { + return p.split('/').pop() || p +} + +export default function Sidebar(): React.JSX.Element { + const sidebarOpen = useAppStore((s) => s.sidebarOpen) + const worktrees = useAppStore((s) => s.worktrees) + const activeWorktree = useAppStore((s) => s.activeWorktree) + const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + + useEffect(() => { + fetchWorktrees() + }, [fetchWorktrees]) + + return ( +
+
Worktrees
+
    + {worktrees.map((wt) => ( +
  • setActiveWorktree(wt.path)} + > + + {wt.isBare ? '(bare)' : branchDisplayName(wt.branch)} + + {pathBasename(wt.path)} +
  • + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx new file mode 100644 index 000000000..a5a63fc7a --- /dev/null +++ b/src/renderer/src/components/Terminal.tsx @@ -0,0 +1,374 @@ +import { useEffect, useRef } from 'react' +import { Restty, getBuiltinTheme } from 'restty' +import { useAppStore } from '../store' + +type PtyTransport = { + connect: (options: { + url: string + cols?: number + rows?: number + callbacks: { + onConnect?: () => void + onDisconnect?: () => void + onData?: (data: string) => void + onStatus?: (shell: string) => void + onError?: (message: string, errors?: string[]) => void + onExit?: (code: number) => void + } + }) => void | Promise + disconnect: () => void + sendInput: (data: string) => boolean + resize: ( + cols: number, + rows: number, + meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number } + ) => boolean + isConnected: () => boolean + destroy?: () => void | Promise +} + +// OSC 0/1/2 title regex: \x1b]N;title(\x07|\x1b\\) +// Handles both BEL and ST terminators, and partial sequences across chunks +const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g + +function extractLastOscTitle(data: string): string | null { + let last: string | null = null + let m: RegExpExecArray | null + OSC_TITLE_RE.lastIndex = 0 + while ((m = OSC_TITLE_RE.exec(data)) !== null) { + last = m[2] + } + return last +} + +function createIpcPtyTransport( + cwd?: string, + onPtyExit?: () => void, + onTitleChange?: (title: string) => void +): PtyTransport { + let connected = false + let ptyId: string | null = null + let storedCallbacks: { + onConnect?: () => void + onDisconnect?: () => void + onData?: (data: string) => void + onStatus?: (shell: string) => void + onError?: (message: string, errors?: string[]) => void + onExit?: (code: number) => void + } = {} + let unsubData: (() => void) | null = null + let unsubExit: (() => void) | null = null + + return { + async connect(options) { + storedCallbacks = options.callbacks + + try { + const result = await window.api.pty.spawn({ + cols: options.cols ?? 80, + rows: options.rows ?? 24, + cwd + }) + ptyId = result.id + connected = true + + unsubData = window.api.pty.onData((payload) => { + if (payload.id === ptyId) { + storedCallbacks.onData?.(payload.data) + if (onTitleChange) { + const title = extractLastOscTitle(payload.data) + if (title !== null) onTitleChange(title) + } + } + }) + + unsubExit = window.api.pty.onExit((payload) => { + if (payload.id === ptyId) { + connected = false + storedCallbacks.onExit?.(payload.code) + storedCallbacks.onDisconnect?.() + onPtyExit?.() + } + }) + + storedCallbacks.onConnect?.() + storedCallbacks.onStatus?.('shell') + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + storedCallbacks.onError?.(msg) + } + }, + + disconnect() { + if (ptyId) { + window.api.pty.kill(ptyId) + connected = false + ptyId = null + unsubData?.() + unsubExit?.() + unsubData = null + unsubExit = null + storedCallbacks.onDisconnect?.() + } + }, + + sendInput(data: string): boolean { + if (!connected || !ptyId) return false + window.api.pty.write(ptyId, data) + return true + }, + + resize(cols: number, rows: number): boolean { + if (!connected || !ptyId) return false + window.api.pty.resize(ptyId, cols, rows) + return true + }, + + isConnected() { + return connected + }, + + destroy() { + this.disconnect() + } + } +} + +export default function Terminal(): React.JSX.Element { + const containerRef = useRef(null) + const resttyRef = useRef(null) + const activeWorktree = useAppStore((s) => s.activeWorktree) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const cwd = activeWorktree ?? undefined + + // Map pane id -> its associated pty transport exit handler + const paneCloseQueue: number[] = [] + let closeTimer: ReturnType | null = null + + function scheduleClose(paneId: number): void { + paneCloseQueue.push(paneId) + if (closeTimer) return + // Batch close on next tick to avoid issues during restty callbacks + closeTimer = setTimeout(() => { + closeTimer = null + const restty = resttyRef.current + if (!restty) return + while (paneCloseQueue.length > 0) { + const id = paneCloseQueue.shift()! + const panes = restty.getPanes() + if (panes.length <= 1) { + // Last pane — go to landing + useAppStore.getState().setShowTerminal(false) + return + } + restty.closePane(id) + } + }, 0) + } + + // Track per-pane titles + const paneTitles = new Map() + let currentActivePaneId: number | null = null + + function syncTitleToStore(): void { + if (currentActivePaneId !== null) { + const title = paneTitles.get(currentActivePaneId) ?? '' + useAppStore.getState().setTerminalTitle(title) + } else { + useAppStore.getState().setTerminalTitle('') + } + } + + const restty = new Restty({ + root: container, + createInitialPane: false, + autoInit: false, + shortcuts: { enabled: true }, + appOptions: ({ id }) => { + const onPtyExit = (): void => { + scheduleClose(id) + } + const onTitleChange = (title: string): void => { + paneTitles.set(id, title) + if (id === currentActivePaneId) { + useAppStore.getState().setTerminalTitle(title) + } + } + return { + renderer: 'webgpu', + fontSize: 14, + fontSizeMode: 'em', + alphaBlending: 'native', + ptyTransport: createIpcPtyTransport(cwd, onPtyExit, onTitleChange) as never, + fontSources: [ + { + type: 'local' as const, + label: 'SF Mono', + matchers: ['sf mono', 'sfmono-regular'], + required: true + }, + { + type: 'local' as const, + label: 'Menlo', + matchers: ['menlo', 'menlo regular'] + } + ] + } + }, + onPaneCreated: async (pane) => { + await pane.app.init() + const theme = getBuiltinTheme('Aizen Dark') + if (theme) pane.app.applyTheme(theme, 'Aizen Dark') + pane.app.updateSize(true) + pane.app.connectPty('') + pane.canvas.focus({ preventScroll: true }) + }, + onPaneClosed: (pane) => { + paneTitles.delete(pane.id) + }, + onActivePaneChange: (pane) => { + currentActivePaneId = pane?.id ?? null + syncTitleToStore() + } + }) + + restty.createInitialPane({ focus: true }) + resttyRef.current = restty + + // --- Pane zoom state --- + let zoomedPaneId: number | null = null + const savedStyles: { el: HTMLElement; prop: string; prev: string }[] = [] + + function saveAndSet(el: HTMLElement, prop: string, value: string): void { + savedStyles.push({ + el, + prop, + prev: (el.style as unknown as Record)[prop] ?? '' + }) + ;(el.style as unknown as Record)[prop] = value + } + + function unzoom(): void { + if (zoomedPaneId === null) return + for (const entry of savedStyles) { + ;(entry.el.style as unknown as Record)[entry.prop] = entry.prev + } + savedStyles.length = 0 + zoomedPaneId = null + requestAnimationFrame(() => { + for (const p of restty.getPanes()) { + p.app.updateSize(true) + } + }) + } + + function togglePaneZoom(): void { + const panes = restty.getPanes() + + if (zoomedPaneId !== null) { + unzoom() + return + } + + if (panes.length <= 1) return + + const active = restty.getActivePane() ?? panes[0] + if (!active) return + + let current: HTMLElement = active.container + saveAndSet(current, 'flex', '1 1 100%') + + while (current.parentElement) { + const parent = current.parentElement + for (const sibling of Array.from(parent.children) as HTMLElement[]) { + if (sibling === current) continue + saveAndSet(sibling, 'display', 'none') + } + if (parent === container) break + saveAndSet(parent, 'flex', '1 1 100%') + current = parent + } + + zoomedPaneId = active.id + requestAnimationFrame(() => { + active.app.updateSize(true) + }) + } + + function closeActivePane(): void { + const panes = restty.getPanes() + const active = restty.getActivePane() ?? panes[0] + if (!active) return + + // If zoomed and closing the zoomed pane, unzoom first + if (zoomedPaneId === active.id) { + unzoom() + } + + if (panes.length <= 1) { + // Last pane — disconnect PTY and go to landing + active.app.disconnectPty() + useAppStore.getState().setShowTerminal(false) + return + } + // Disconnect PTY then close the pane + active.app.disconnectPty() + restty.closePane(active.id) + } + + const onKeyDown = (e: KeyboardEvent): void => { + // Cmd+K: clear screen + if (e.metaKey && e.key === 'k' && !e.shiftKey && !e.repeat) { + e.preventDefault() + const pane = restty.getActivePane() ?? restty.getPanes()[0] + if (pane) { + pane.app.clearScreen() + } + return + } + + // Cmd+W: close active pane (not the window) + if (e.metaKey && e.key === 'w' && !e.shiftKey && !e.repeat) { + e.preventDefault() + closeActivePane() + return + } + + // Cmd+] / Cmd+[: cycle through panes + if (e.metaKey && !e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) { + const panes = restty.getPanes() + if (panes.length > 1) { + e.preventDefault() + const active = restty.getActivePane() + const idx = active ? panes.findIndex((p) => p.id === active.id) : -1 + const dir = e.key === ']' ? 1 : -1 + const next = panes[(idx + dir + panes.length) % panes.length] + if (next) { + next.canvas.focus({ preventScroll: true }) + } + } + return + } + + // Cmd+Shift+Enter: toggle pane zoom + if (e.metaKey && e.shiftKey && e.key === 'Enter' && !e.repeat) { + e.preventDefault() + togglePaneZoom() + } + } + window.addEventListener('keydown', onKeyDown, { capture: true }) + + return () => { + window.removeEventListener('keydown', onKeyDown, { capture: true }) + if (closeTimer) clearTimeout(closeTimer) + restty.destroy() + resttyRef.current = null + } + }, [activeWorktree]) + + return
+} diff --git a/src/renderer/src/components/Versions.tsx b/src/renderer/src/components/Versions.tsx deleted file mode 100644 index 37a9ff082..000000000 --- a/src/renderer/src/components/Versions.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useState } from 'react' - -function Versions(): React.JSX.Element { - const [versions] = useState(window.electron.process.versions) - - return ( -
    -
  • Electron v{versions.electron}
  • -
  • Chromium v{versions.chrome}
  • -
  • Node v{versions.node}
  • -
- ) -} - -export default Versions diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts new file mode 100644 index 000000000..d2ea58d4b --- /dev/null +++ b/src/renderer/src/store.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand' + +export interface Worktree { + path: string + head: string + branch: string + isBare: boolean +} + +export interface AppState { + sidebarOpen: boolean + toggleSidebar: () => void + + worktrees: Worktree[] + activeWorktree: string | null + setActiveWorktree: (path: string) => void + fetchWorktrees: () => Promise + + showTerminal: boolean + setShowTerminal: (show: boolean) => void + + terminalTitle: string + setTerminalTitle: (title: string) => void +} + +export const useAppStore = create((set) => ({ + sidebarOpen: true, + toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), + + worktrees: [], + activeWorktree: null, + setActiveWorktree: (path) => set({ activeWorktree: path }), + fetchWorktrees: async () => { + try { + const worktrees = await window.api.worktrees.getCurrent() + set({ worktrees }) + } catch (err) { + console.error('Failed to fetch worktrees:', err) + } + }, + + showTerminal: true, + setShowTerminal: (show) => set({ showTerminal: show }), + + terminalTitle: '', + setTerminalTitle: (title) => set({ terminalTitle: title }) +}))