diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c0bfec6 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "deepcode-desktop", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@deepcode/desktop", "dev"], + "port": 5173 + } + ] +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 891995b..9a10987 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js'; +import { FilePanel } from './components/FilePanel.js'; import { InspectorPanel } from './components/InspectorPanel.js'; import { InspectorRail } from './components/InspectorRail.js'; import { ProjectPickerOverlay } from './components/ProjectPickerOverlay.js'; @@ -15,6 +16,7 @@ import { clearHistory as clearAgentHistory } from './lib/mac-agent.js'; import { loadProjectPath, saveProjectPath } from './lib/project.js'; import { storedToMsgs, type Msg } from './lib/repl-stream.js'; import { onUpdateDownloaded, startUpdaterPolling } from './lib/updater.js'; +import { useFilePanel } from './lib/use-file-panel.js'; import { AboutScreen } from './screens/About.js'; import { MCPManagerScreen } from './screens/MCPManager.js'; import { OnboardingScreen } from './screens/Onboarding.js'; @@ -47,6 +49,25 @@ export function App(): JSX.Element { // Which section to scroll to when expanding via a rail hint icon (null = top). const [inspectorFocus, setInspectorFocus] = useState(null); const [inspector, setInspector] = useState(() => emptyInspectorData()); + // Right-side file panel (§3.11): opens between chat and the inspector rail. + const fp = useFilePanel(); + + // Drag the panel's left edge to resize (320–800px, persisted by the hook). + const onFilePanelResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startW = fp.state.width; + const move = (ev: MouseEvent): void => fp.setWidth(startW + (startX - ev.clientX)); + const up = (): void => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }, + [fp.state.width, fp.setWidth], + ); // Expand the inspector, optionally scrolling to a section. Bumps focus to a // fresh value each time so re-clicking the same icon re-scrolls. @@ -78,7 +99,6 @@ export function App(): JSX.Element { }); const offComma = registerShortcut('meta+,', () => setScreen('settings')); const offSlash = registerShortcut('meta+/', () => setScreen('about')); - const offBackslash = registerShortcut('meta+\\', () => setInspectorExpanded((v) => !v)); return () => { offShim(); @@ -86,10 +106,19 @@ export function App(): JSX.Element { offN(); offComma(); offSlash(); - offBackslash(); }; }, []); + // ⌘\ is context-sensitive: when the file panel is showing a diff it toggles + // split/inline (§3.11); otherwise it expands/collapses the inspector (§3.10a). + // Re-registered when that context changes so it never reads stale state. + useEffect(() => { + return registerShortcut('meta+\\', () => { + if (fp.isOpen && fp.state.view === 'diff') fp.toggleDiffMode(); + else setInspectorExpanded((v) => !v); + }); + }, [fp.isOpen, fp.state.view, fp.toggleDiffMode]); + async function handlePickProject(path: string): Promise { await saveProjectPath(path); setProjectPath(path); @@ -130,8 +159,14 @@ export function App(): JSX.Element { const usedTokens = inspector.usage.inputTokens + inspector.usage.outputTokens; const contextFill = usedTokens > 0 ? usedTokens / contextWindowFor(inspector.model) : undefined; + // When the file panel is open it inserts a 4th column and the inspector stays + // a 48px rail (§3.11); otherwise the 3-column shell with an optional 320px + // inspector panel. + const shellClass = + 'app-shell' + (fp.isOpen ? ' file-open' : inspectorExpanded ? ' inspector-open' : ''); + return ( -
+
{update && } - {inspectorExpanded ? ( + {fp.isOpen && ( + {}} + onResizeStart={onFilePanelResizeStart} + /> + )} + {inspectorExpanded && !fp.isOpen ? ( setInspectorExpanded(false)} + onOpenFile={(path) => void fp.open(path)} /> ) : ( void; + onCloseTab: (index: number) => void; + onSelectView: (view: FileView) => void; + onToggleDiffMode: () => void; + onSelectHistory: (ts: number) => void; + /** Mousedown on the left-edge resize grip — the parent runs the drag. */ + onResizeStart: (e: React.MouseEvent) => void; +} + +const VIEWS: { id: FileView; label: string }[] = [ + { id: 'source', label: 'Source' }, + { id: 'diff', label: 'Diff' }, + { id: 'history', label: 'History' }, +]; + +export function FilePanel({ + tabs, + activeIndex, + view, + diffMode, + width, + onSelectTab, + onCloseTab, + onSelectView, + onToggleDiffMode, + onSelectHistory, + onResizeStart, +}: FilePanelProps): JSX.Element { + const active = tabs[activeIndex] ?? tabs[0]; + + return ( + + ); +} + +function SourceView({ content }: { content: string }): JSX.Element { + const lines = content.split('\n'); + return ( +
+ {lines.map((ln, i) => ( +
+ {i + 1} + {ln === '' ? ' ' : ln} +
+ ))} +
+ ); +} + +function DiffView({ lines, mode }: { lines: DiffLine[] | null; mode: DiffMode }): JSX.Element { + if (!lines || lines.length === 0) { + return

No diff — this file hasn’t been edited this session.

; + } + if (mode === 'inline') { + return ( +
+ {lines.map((l, i) => ( +
+ {l.oldNo ?? ''} + {l.newNo ?? ''} + {l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' '} + {l.text === '' ? ' ' : l.text} +
+ ))} +
+ ); + } + // split: old (del+ctx) on the left, new (add+ctx) on the right, row-aligned. + return ( +
+ {lines.map((l, i) => ( +
+
+ {l.kind !== 'add' && ( + <> + {l.oldNo ?? ''} + {l.text === '' ? ' ' : l.text} + + )} +
+
+ {l.kind !== 'del' && ( + <> + {l.newNo ?? ''} + {l.text === '' ? ' ' : l.text} + + )} +
+
+ ))} +
+ ); +} + +function HistoryView({ + entries, + onSelect, +}: { + entries: FileTab['history']; + onSelect: (ts: number) => void; +}): JSX.Element { + if (entries.length === 0) { + return

No version history for this file yet.

; + } + return ( +
+ {entries.map((e) => ( + + ))} +
+ ); +} + +// ─── helpers ────────────────────────────────────────────────────────── +function basename(p: string): string { + const parts = p.split('/').filter(Boolean); + return parts[parts.length - 1] ?? p; +} + +function fmtTime(ts: number): string { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +} diff --git a/apps/desktop/src/components/InspectorPanel.tsx b/apps/desktop/src/components/InspectorPanel.tsx index 1fa745d..658e281 100644 --- a/apps/desktop/src/components/InspectorPanel.tsx +++ b/apps/desktop/src/components/InspectorPanel.tsx @@ -25,6 +25,8 @@ interface InspectorPanelProps { * clicked one of the rail's hint icons (▤/◐/📁/ⓘ) rather than the chevron. */ focusSection?: InspectorSection | null; + /** Open a recent file in the right-side file panel (§3.11). */ + onOpenFile?: (path: string) => void; } const MODE_LABELS: Record = { @@ -40,6 +42,7 @@ export function InspectorPanel({ data, onCollapse, focusSection, + onOpenFile, }: InspectorPanelProps): JSX.Element { const { usage, costYuan, model, mode, recentFiles, todos } = data; @@ -112,7 +115,15 @@ export function InspectorPanel({ ) : (
{recentFiles.map((f) => ( -
+
onOpenFile(f) : undefined} + style={onOpenFile ? { cursor: 'pointer' } : undefined} + > {basename(f)} {dirname(f)}
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index faa6204..eaa7283 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -1141,3 +1141,315 @@ select { opacity: 0; } } + +/* ──────────────────────────────────────────────────────────────────── */ +/* Right-side file panel (§3.11) — between chat and the inspector rail. */ +/* The .file-open modifier inserts an auto-sized track; the panel's own */ +/* inline width (320–800px) drives it, so dragging resizes the column. */ +/* ──────────────────────────────────────────────────────────────────── */ + +.app-shell.file-open { + grid-template-columns: 240px 1fr auto 48px; +} + +.file-panel { + position: relative; + background: var(--bg-0); + border-left: 1px solid var(--line); + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 320px; + max-width: 800px; + /* Never shrink below the dragged width (the grid `auto` track sizes to it). */ + flex-shrink: 0; +} +.fp-resize { + position: absolute; + left: -3px; + top: 0; + bottom: 0; + width: 6px; + cursor: col-resize; + z-index: 5; +} +.fp-resize:hover { + background: var(--brand-line); +} + +/* tab bar */ +.fp-tabs { + display: flex; + align-items: stretch; + gap: 2px; + padding: 6px 8px 0; + background: var(--bg-1); + border-bottom: 1px solid var(--line); + overflow-x: auto; + flex-shrink: 0; +} +.fp-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 8px 7px 12px; + max-width: 180px; + border: 1px solid transparent; + border-bottom: none; + border-radius: 8px 8px 0 0; + font-size: 12px; + color: var(--text-2); + cursor: pointer; + white-space: nowrap; +} +.fp-tab:hover { + color: var(--text-1); + background: var(--bg-2); +} +.fp-tab.active { + color: var(--text-0); + background: var(--bg-0); + border-color: var(--line); +} +.fp-tab-name { + overflow: hidden; + text-overflow: ellipsis; +} +.fp-tab-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--warn); + flex-shrink: 0; +} +.fp-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: transparent; + color: var(--text-3); + border-radius: 4px; + cursor: pointer; + font-size: 14px; + line-height: 1; +} +.fp-tab-close:hover { + background: var(--bg-3); + color: var(--text-0); +} + +/* view switcher toolbar */ +.fp-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--line); + background: var(--bg-0); + flex-shrink: 0; +} +.fp-views { + display: inline-flex; + background: var(--bg-2); + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + padding: 2px; + gap: 2px; +} +.fp-view-btn { + padding: 4px 12px; + border: none; + background: transparent; + color: var(--text-2); + font-size: 12px; + border-radius: 4px; + cursor: pointer; +} +.fp-view-btn:hover { + color: var(--text-0); +} +.fp-view-btn.active { + background: var(--brand); + color: #fff; + font-weight: 600; +} +.fp-toolbar-spacer { + flex: 1; +} +.fp-action { + padding: 4px 10px; + border: 1px solid var(--line-soft); + background: var(--bg-1); + color: var(--text-1); + font-size: 12px; + border-radius: var(--radius-sm); + cursor: pointer; +} +.fp-action:hover { + border-color: var(--line); + color: var(--text-0); +} + +/* body + code views */ +.fp-body { + flex: 1; + overflow: auto; + font-family: 'JetBrains Mono', monospace; + font-size: 12.5px; + line-height: 1.55; +} +.fp-empty { + color: var(--text-3); + font-family: 'Inter', sans-serif; + font-size: 12.5px; + padding: 18px; +} +.fp-source { + padding: 8px 0; +} +.fp-line { + display: flex; + gap: 0; + white-space: pre; +} +.fp-line:hover { + background: var(--bg-1); +} +.fp-ln { + display: inline-block; + min-width: 44px; + padding: 0 12px 0 0; + text-align: right; + color: var(--text-3); + user-select: none; + flex-shrink: 0; +} +.fp-code { + color: var(--text-1); + white-space: pre; +} + +/* diff — inline */ +.fp-diff.inline { + padding: 8px 0; +} +.fp-dline { + display: flex; + align-items: baseline; + white-space: pre; +} +.fp-dline .fp-ln { + min-width: 38px; + font-size: 11px; +} +.fp-dline .fp-ln.old { + padding-right: 4px; +} +.fp-dline .fp-sign { + width: 16px; + text-align: center; + color: var(--text-3); + flex-shrink: 0; +} +.fp-dline.add { + background: rgba(20, 228, 162, 0.1); +} +.fp-dline.add .fp-sign, +.fp-dline.add .fp-code { + color: var(--accent); +} +.fp-dline.del { + background: rgba(255, 84, 112, 0.1); +} +.fp-dline.del .fp-sign, +.fp-dline.del .fp-code { + color: var(--error); +} + +/* diff — split */ +.fp-diff.split { + padding: 8px 0; +} +.fp-drow { + display: grid; + grid-template-columns: 1fr 1fr; +} +.fp-dcell { + display: flex; + white-space: pre; + overflow: hidden; + border-right: 1px solid var(--line-soft); +} +.fp-dcell .fp-ln { + min-width: 38px; + font-size: 11px; +} +.fp-dcell.del { + background: rgba(255, 84, 112, 0.1); +} +.fp-dcell.del .fp-code { + color: var(--error); +} +.fp-dcell.add { + background: rgba(20, 228, 162, 0.1); +} +.fp-dcell.add .fp-code { + color: var(--accent); +} +.fp-dcell.blank { + background: var(--bg-1); +} + +/* history timeline */ +.fp-history { + padding: 10px; + font-family: 'Inter', sans-serif; + display: flex; + flex-direction: column; + gap: 2px; +} +.fp-hist-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-1); + font-size: 12.5px; + cursor: pointer; + text-align: left; + width: 100%; +} +.fp-hist-row:hover { + background: var(--bg-2); + border-color: var(--line-soft); +} +.fp-hist-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--brand); + flex-shrink: 0; +} +.fp-hist-label { + flex: 1; + color: var(--text-0); +} +.fp-hist-tool { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-2); + background: var(--bg-2); + padding: 1px 6px; + border-radius: 4px; +} +.fp-hist-ts { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-3); +} diff --git a/apps/desktop/src/lib/file-panel-reducer.test.ts b/apps/desktop/src/lib/file-panel-reducer.test.ts new file mode 100644 index 0000000..81740a5 --- /dev/null +++ b/apps/desktop/src/lib/file-panel-reducer.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import type { FileTab } from '../types/file-panel.js'; +import { filePanelReducer, initialFilePanelState } from './file-panel-reducer.js'; + +const tab = (path: string): FileTab => ({ path, source: `// ${path}`, diff: null, history: [] }); + +describe('filePanelReducer', () => { + it('open adds a tab and activates it', () => { + let s = initialFilePanelState(); + s = filePanelReducer(s, { type: 'open', tab: tab('/a.ts') }); + s = filePanelReducer(s, { type: 'open', tab: tab('/b.ts') }); + expect(s.tabs.map((t) => t.path)).toEqual(['/a.ts', '/b.ts']); + expect(s.activeIndex).toBe(1); + }); + + it('re-opening an existing path refreshes data and activates without duplicating', () => { + let s = initialFilePanelState(); + s = filePanelReducer(s, { type: 'open', tab: tab('/a.ts') }); + s = filePanelReducer(s, { type: 'open', tab: tab('/b.ts') }); + s = filePanelReducer(s, { + type: 'open', + tab: { ...tab('/a.ts'), source: 'updated', unsaved: true }, + }); + expect(s.tabs).toHaveLength(2); + expect(s.activeIndex).toBe(0); + expect(s.tabs[0]?.source).toBe('updated'); + expect(s.tabs[0]?.unsaved).toBe(true); + }); + + it('close shifts activeIndex correctly when closing before the active tab', () => { + let s = initialFilePanelState(); + for (const p of ['/a.ts', '/b.ts', '/c.ts']) + s = filePanelReducer(s, { type: 'open', tab: tab(p) }); + // active is /c.ts (index 2); close /a.ts (index 0) → active stays /c.ts (now index 1) + s = filePanelReducer(s, { type: 'close', index: 0 }); + expect(s.tabs.map((t) => t.path)).toEqual(['/b.ts', '/c.ts']); + expect(s.activeIndex).toBe(1); + }); + + it('closing the active last tab moves active to the new last', () => { + let s = initialFilePanelState(); + for (const p of ['/a.ts', '/b.ts']) s = filePanelReducer(s, { type: 'open', tab: tab(p) }); + s = filePanelReducer(s, { type: 'close', index: 1 }); // close active /b.ts + expect(s.tabs.map((t) => t.path)).toEqual(['/a.ts']); + expect(s.activeIndex).toBe(0); + }); + + it('close ignores out-of-range index', () => { + let s = initialFilePanelState(); + s = filePanelReducer(s, { type: 'open', tab: tab('/a.ts') }); + const before = s; + s = filePanelReducer(s, { type: 'close', index: 9 }); + expect(s).toBe(before); + }); + + it('view + toggleDiffMode', () => { + let s = initialFilePanelState(); + s = filePanelReducer(s, { type: 'view', view: 'diff' }); + expect(s.view).toBe('diff'); + expect(s.diffMode).toBe('inline'); + s = filePanelReducer(s, { type: 'toggleDiffMode' }); + expect(s.diffMode).toBe('split'); + s = filePanelReducer(s, { type: 'toggleDiffMode' }); + expect(s.diffMode).toBe('inline'); + }); + + it('width is clamped to 320–800', () => { + let s = initialFilePanelState(); + s = filePanelReducer(s, { type: 'width', width: 100 }); + expect(s.width).toBe(320); + s = filePanelReducer(s, { type: 'width', width: 9999 }); + expect(s.width).toBe(800); + s = filePanelReducer(s, { type: 'width', width: 555 }); + expect(s.width).toBe(555); + }); + + it('next/prev wrap around the open tabs', () => { + let s = initialFilePanelState(); + for (const p of ['/a.ts', '/b.ts', '/c.ts']) + s = filePanelReducer(s, { type: 'open', tab: tab(p) }); + s = filePanelReducer(s, { type: 'select', index: 2 }); + s = filePanelReducer(s, { type: 'nextTab' }); // wraps to 0 + expect(s.activeIndex).toBe(0); + s = filePanelReducer(s, { type: 'prevTab' }); // wraps to 2 + expect(s.activeIndex).toBe(2); + }); + + it('next/prev are no-ops with no tabs', () => { + const s = initialFilePanelState(); + expect(filePanelReducer(s, { type: 'nextTab' })).toBe(s); + expect(filePanelReducer(s, { type: 'prevTab' })).toBe(s); + }); +}); diff --git a/apps/desktop/src/lib/file-panel-reducer.ts b/apps/desktop/src/lib/file-panel-reducer.ts new file mode 100644 index 0000000..90b75dd --- /dev/null +++ b/apps/desktop/src/lib/file-panel-reducer.ts @@ -0,0 +1,76 @@ +// Pure state machine for the right-side file panel (§3.11). Kept separate from +// the React hook so the tab/view/width transitions are unit-testable without a +// renderer. The hook (useFilePanel) wraps this with useReducer + side effects +// (reading files, persisting width, keybindings). + +import { + clampPanelWidth, + FILE_PANEL_DEFAULT_WIDTH, + type FilePanelState, + type FileTab, + type FileView, +} from '../types/file-panel.js'; + +export type FilePanelAction = + | { type: 'open'; tab: FileTab } + | { type: 'close'; index: number } + | { type: 'select'; index: number } + | { type: 'view'; view: FileView } + | { type: 'toggleDiffMode' } + | { type: 'width'; width: number } + | { type: 'nextTab' } + | { type: 'prevTab' }; + +export function initialFilePanelState(width = FILE_PANEL_DEFAULT_WIDTH): FilePanelState { + return { + tabs: [], + activeIndex: 0, + view: 'source', + diffMode: 'inline', + width: clampPanelWidth(width), + }; +} + +export function filePanelReducer(state: FilePanelState, action: FilePanelAction): FilePanelState { + switch (action.type) { + case 'open': { + // Re-opening an already-open file refreshes its data + activates it. + const existing = state.tabs.findIndex((t) => t.path === action.tab.path); + if (existing >= 0) { + const tabs = state.tabs.slice(); + tabs[existing] = action.tab; + return { ...state, tabs, activeIndex: existing }; + } + return { ...state, tabs: [...state.tabs, action.tab], activeIndex: state.tabs.length }; + } + case 'close': { + if (action.index < 0 || action.index >= state.tabs.length) return state; + const tabs = state.tabs.filter((_, i) => i !== action.index); + let activeIndex = state.activeIndex; + if (action.index < activeIndex) activeIndex -= 1; + if (activeIndex >= tabs.length) activeIndex = tabs.length - 1; + if (activeIndex < 0) activeIndex = 0; + return { ...state, tabs, activeIndex }; + } + case 'select': + if (action.index < 0 || action.index >= state.tabs.length) return state; + return { ...state, activeIndex: action.index }; + case 'view': + return { ...state, view: action.view }; + case 'toggleDiffMode': + return { ...state, diffMode: state.diffMode === 'split' ? 'inline' : 'split' }; + case 'width': + return { ...state, width: clampPanelWidth(action.width) }; + case 'nextTab': + if (state.tabs.length === 0) return state; + return { ...state, activeIndex: (state.activeIndex + 1) % state.tabs.length }; + case 'prevTab': + if (state.tabs.length === 0) return state; + return { + ...state, + activeIndex: (state.activeIndex - 1 + state.tabs.length) % state.tabs.length, + }; + default: + return state; + } +} diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index 4fdd5b7..8c54b04 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -189,6 +189,15 @@ export async function cliPath(): Promise { return (await invoke('cli_path')) as string | null; } +/** + * Read a file's text via the unscoped `tool_read` Rust command (the file panel's + * Source view). Returns up to the first 2000 lines (tool_read's default limit). + */ +export async function toolRead(filePath: string): Promise { + const r = (await invoke('tool_read', { filePath })) as { content: string }; + return r.content; +} + /** Open a URL in the user's default browser. */ export async function openUrl(url: string): Promise { const { openUrl: openerOpen } = await import('@tauri-apps/plugin-opener'); diff --git a/apps/desktop/src/lib/use-file-panel.ts b/apps/desktop/src/lib/use-file-panel.ts new file mode 100644 index 0000000..6a394a3 --- /dev/null +++ b/apps/desktop/src/lib/use-file-panel.ts @@ -0,0 +1,105 @@ +// React hook wiring the pure file-panel reducer to side effects: reading file +// contents (Source view), persisting the panel width, and the ⌘O / ⌘[ / ⌘] +// keybindings. The ⌘\ split/inline toggle is owned by App (it shares the chord +// with the inspector toggle and resolves contextually). +// +// Diff/History data is left empty here — those are backed by session snapshots, +// wired in a follow-up. The component shows honest empty states meanwhile. + +import { useCallback, useEffect, useReducer } from 'react'; +import { pickFile, toolRead } from './tauri-api.js'; +import { registerShortcut } from './keyboard.js'; +import { filePanelReducer, initialFilePanelState } from './file-panel-reducer.js'; +import type { FileView } from '../types/file-panel.js'; + +const WIDTH_KEY = 'deepcode.filePanel.width'; + +function loadWidth(): number | undefined { + try { + const v = localStorage.getItem(WIDTH_KEY); + return v ? Number(v) : undefined; + } catch { + return undefined; + } +} + +export interface UseFilePanel { + state: ReturnType; + isOpen: boolean; + open: (path: string) => Promise; + openViaPicker: () => Promise; + close: (index: number) => void; + closeActive: () => void; + select: (index: number) => void; + setView: (view: FileView) => void; + toggleDiffMode: () => void; + setWidth: (width: number) => void; +} + +export function useFilePanel(): UseFilePanel { + const [state, dispatch] = useReducer(filePanelReducer, loadWidth(), initialFilePanelState); + + const open = useCallback(async (path: string): Promise => { + let source: string; + try { + source = await toolRead(path); + } catch (e) { + source = `// Could not read ${path}\n// ${String(e)}`; + } + dispatch({ type: 'open', tab: { path, source, diff: null, history: [] } }); + }, []); + + const openViaPicker = useCallback(async (): Promise => { + try { + const p = await pickFile(); + if (p) await open(p); + } catch { + /* picker cancelled */ + } + }, [open]); + + const close = useCallback((index: number) => dispatch({ type: 'close', index }), []); + const select = useCallback((index: number) => dispatch({ type: 'select', index }), []); + const setView = useCallback((view: FileView) => dispatch({ type: 'view', view }), []); + const toggleDiffMode = useCallback(() => dispatch({ type: 'toggleDiffMode' }), []); + const setWidth = useCallback((width: number) => dispatch({ type: 'width', width }), []); + + // Persist the panel width across launches. + useEffect(() => { + try { + localStorage.setItem(WIDTH_KEY, String(state.width)); + } catch { + /* storage unavailable */ + } + }, [state.width]); + + // ⌘O open a file · ⌘[ / ⌘] previous/next tab. + useEffect(() => { + const offOpen = registerShortcut('meta+o', () => void openViaPicker()); + const offPrev = registerShortcut('meta+[', () => dispatch({ type: 'prevTab' })); + const offNext = registerShortcut('meta+]', () => dispatch({ type: 'nextTab' })); + return () => { + offOpen(); + offPrev(); + offNext(); + }; + }, [openViaPicker]); + + const closeActive = useCallback( + () => dispatch({ type: 'close', index: state.activeIndex }), + [state.activeIndex], + ); + + return { + state, + isOpen: state.tabs.length > 0, + open, + openViaPicker, + close, + closeActive, + select, + setView, + toggleDiffMode, + setWidth, + }; +} diff --git a/apps/desktop/src/preview-filepanel.tsx b/apps/desktop/src/preview-filepanel.tsx new file mode 100644 index 0000000..387cf7a --- /dev/null +++ b/apps/desktop/src/preview-filepanel.tsx @@ -0,0 +1,102 @@ +// DEV-ONLY preview harness for the FilePanel (§3.11). Not part of the prod +// bundle — vite's build input is pinned to index.html, so this page only +// exists under `vite dev` (served at /preview.html) for visual iteration. +// +// Mounts FilePanel with mock fixtures + a working resize drag so the panel's +// appearance can be screenshotted without the Tauri backend. + +import { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { FilePanel } from './components/FilePanel.js'; +import { + clampPanelWidth, + FILE_PANEL_DEFAULT_WIDTH, + type DiffMode, + type FileTab, + type FileView, +} from './types/file-panel.js'; +import './index.css'; + +const SAMPLE = `export function greet(name: string): string { + // build the greeting + const prefix = 'Hello'; + return prefix + ', ' + name + '!'; +} + +export const VERSION = '0.2.0'; +`; + +const MOCK_TABS: FileTab[] = [ + { + path: '/Users/oratis/Projects/Claude/DeepCode/packages/core/src/greet.ts', + source: SAMPLE, + unsaved: true, + diff: [ + { kind: 'ctx', oldNo: 1, newNo: 1, text: 'export function greet(name: string): string {' }, + { kind: 'del', oldNo: 2, newNo: null, text: " return 'Hello ' + name;" }, + { kind: 'add', oldNo: null, newNo: 2, text: ' // build the greeting' }, + { kind: 'add', oldNo: null, newNo: 3, text: " const prefix = 'Hello';" }, + { kind: 'add', oldNo: null, newNo: 4, text: " return prefix + ', ' + name + '!';" }, + { kind: 'ctx', oldNo: 3, newNo: 5, text: '}' }, + ], + history: [ + { ts: 1717286400000, tool: 'Edit', label: 'after Edit — add greeting prefix' }, + { ts: 1717286280000, tool: 'Edit', label: 'before Edit' }, + { ts: 1717286100000, tool: 'Write', label: 'initial Write' }, + ], + }, + { + path: '/Users/oratis/Projects/Claude/DeepCode/README.md', + source: '# DeepCode\n\nA DeepSeek-driven coding agent.\n', + diff: null, + history: [], + }, +]; + +function Harness(): JSX.Element { + const [activeIndex, setActiveIndex] = useState(0); + const [view, setView] = useState('source'); + const [diffMode, setDiffMode] = useState('inline'); + const [width, setWidth] = useState(FILE_PANEL_DEFAULT_WIDTH); + + const onResizeStart = (e: React.MouseEvent): void => { + e.preventDefault(); + const startX = e.clientX; + const startW = width; + const move = (ev: MouseEvent): void => + setWidth(clampPanelWidth(startW + (startX - ev.clientX))); + const up = (): void => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + return ( +
+
+
‹ chat column (mock) — width {width}px
+
+ {}} + onSelectView={setView} + onToggleDiffMode={() => setDiffMode((m) => (m === 'split' ? 'inline' : 'split'))} + onSelectHistory={() => {}} + onResizeStart={onResizeStart} + /> +
+
+ ); +} + +const rootEl = document.getElementById('root'); +if (rootEl) createRoot(rootEl).render(); diff --git a/apps/desktop/src/preview.html b/apps/desktop/src/preview.html new file mode 100644 index 0000000..c2d094f --- /dev/null +++ b/apps/desktop/src/preview.html @@ -0,0 +1,12 @@ + + + + + + FilePanel preview (dev only) + + +
+ + + diff --git a/apps/desktop/src/types/file-panel.ts b/apps/desktop/src/types/file-panel.ts new file mode 100644 index 0000000..c157f02 --- /dev/null +++ b/apps/desktop/src/types/file-panel.ts @@ -0,0 +1,62 @@ +// Right-side file panel (§3.11) — types shared by the presentational +// FilePanel component, its useFilePanel state hook, and the diff util. +// +// The panel is PURELY presentational: it renders whatever tabs/views the +// parent hands it and calls back for every interaction. Data fetching +// (reading files + session snapshots via Tauri) lives in the parent so the +// component stays unit-testable + previewable without a Tauri backend. + +export type FileView = 'source' | 'diff' | 'history'; +export type DiffMode = 'split' | 'inline'; + +/** One rendered line of a unified/side-by-side diff. */ +export interface DiffLine { + kind: 'add' | 'del' | 'ctx'; + /** 1-based line number in the OLD revision (null for added lines). */ + oldNo: number | null; + /** 1-based line number in the NEW revision (null for deleted lines). */ + newNo: number | null; + text: string; +} + +/** One entry in a file's session version timeline (newest first). */ +export interface FileHistoryEntry { + /** Snapshot timestamp (unix ms). */ + ts: number; + /** Tool that produced the snapshot. */ + tool: string; + /** Human label, e.g. "before Edit" / "after Write". */ + label: string; +} + +/** One open file tab. */ +export interface FileTab { + /** Absolute path (the tab identity). */ + path: string; + /** Current source content (Source view). */ + source: string; + /** Precomputed diff vs the last Edit/Write, or null when none exists. */ + diff: DiffLine[] | null; + /** Session version timeline, newest first. */ + history: FileHistoryEntry[]; + /** Show the unsaved-changes yellow dot. */ + unsaved?: boolean; +} + +/** Persisted-ish panel UI state (width persists to settings.local.json). */ +export interface FilePanelState { + tabs: FileTab[]; + activeIndex: number; + view: FileView; + diffMode: DiffMode; + width: number; +} + +export const FILE_PANEL_MIN_WIDTH = 320; +export const FILE_PANEL_MAX_WIDTH = 800; +export const FILE_PANEL_DEFAULT_WIDTH = 520; + +/** Clamp a width to the spec's 320–800px range. */ +export function clampPanelWidth(w: number): number { + return Math.max(FILE_PANEL_MIN_WIDTH, Math.min(FILE_PANEL_MAX_WIDTH, Math.round(w))); +}