Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "deepcode-desktop",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@deepcode/desktop", "dev"],
"port": 5173
}
]
}
59 changes: 55 additions & 4 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<InspectorSection | null>(null);
const [inspector, setInspector] = useState<InspectorData>(() => 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.
Expand Down Expand Up @@ -78,18 +99,26 @@ 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();
offReal();
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<void> {
await saveProjectPath(path);
setProjectPath(path);
Expand Down Expand Up @@ -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 (
<div className={'app-shell' + (inspectorExpanded ? ' inspector-open' : '')}>
<div className={shellClass}>
{update && <UpdateBanner info={update} />}
<Sidebar
key={`sb-${sessionEpoch}`}
Expand Down Expand Up @@ -179,12 +214,28 @@ export function App(): JSX.Element {
resumedMessages,
)}
</main>
{inspectorExpanded ? (
{fp.isOpen && (
<FilePanel
tabs={fp.state.tabs}
activeIndex={fp.state.activeIndex}
view={fp.state.view}
diffMode={fp.state.diffMode}
width={fp.state.width}
onSelectTab={fp.select}
onCloseTab={fp.close}
onSelectView={fp.setView}
onToggleDiffMode={fp.toggleDiffMode}
onSelectHistory={() => {}}
onResizeStart={onFilePanelResizeStart}
/>
)}
{inspectorExpanded && !fp.isOpen ? (
<InspectorPanel
projectPath={projectPath}
data={inspector}
focusSection={inspectorFocus}
onCollapse={() => setInspectorExpanded(false)}
onOpenFile={(path) => void fp.open(path)}
/>
) : (
<InspectorRail
Expand Down
229 changes: 229 additions & 0 deletions apps/desktop/src/components/FilePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Right-side file panel (§3.11) — Mac client only.
// Inserts between the chat column and the 48px inspector rail. Presentational
// only: the parent (useFilePanel) owns the tabs/views/width state and the data
// fetching (file reads + session snapshots via Tauri). Every interaction is a
// callback so the component is unit-testable + previewable without a backend.

import type { DiffLine, DiffMode, FileTab, FileView } from '../types/file-panel.js';

interface FilePanelProps {
tabs: FileTab[];
activeIndex: number;
view: FileView;
diffMode: DiffMode;
/** Current panel width (px); the parent persists it to settings.local.json. */
width: number;
onSelectTab: (index: number) => 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 (
<aside className="file-panel" style={{ width: `${width}px` }} data-testid="file-panel">
<div className="fp-resize" onMouseDown={onResizeStart} title="Drag to resize" />

{/* ── tab bar ─────────────────────────────────────────────── */}
<div className="fp-tabs" role="tablist">
{tabs.map((t, i) => (
<div
key={t.path}
className={'fp-tab' + (i === activeIndex ? ' active' : '')}
role="tab"
aria-selected={i === activeIndex}
title={t.path}
onClick={() => onSelectTab(i)}
>
<span className="fp-tab-name">{basename(t.path)}</span>
{t.unsaved && <span className="fp-tab-dot" title="Unsaved changes" />}
<button
type="button"
className="fp-tab-close"
title="Close tab (⌘W)"
onClick={(e) => {
e.stopPropagation();
onCloseTab(i);
}}
>
×
</button>
</div>
))}
</div>

{/* ── view switcher + per-view actions ────────────────────── */}
<div className="fp-toolbar">
<div className="fp-views" role="tablist">
{VIEWS.map((v) => (
<button
key={v.id}
type="button"
className={'fp-view-btn' + (view === v.id ? ' active' : '')}
onClick={() => onSelectView(v.id)}
>
{v.label}
</button>
))}
</div>
<span className="fp-toolbar-spacer" />
{view === 'diff' && (
<button
type="button"
className="fp-action"
title="Toggle split / inline (⌘\\)"
onClick={onToggleDiffMode}
>
{diffMode === 'split' ? '⇆ Split' : '≡ Inline'}
</button>
)}
{view === 'source' && (
<button type="button" className="fp-action" title="Edit (read-only for now)">
</button>
)}
</div>

{/* ── body ────────────────────────────────────────────────── */}
<div className="fp-body">
{!active ? (
<p className="fp-empty">No file open.</p>
) : view === 'source' ? (
<SourceView content={active.source} />
) : view === 'diff' ? (
<DiffView lines={active.diff} mode={diffMode} />
) : (
<HistoryView entries={active.history} onSelect={onSelectHistory} />
)}
</div>
</aside>
);
}

function SourceView({ content }: { content: string }): JSX.Element {
const lines = content.split('\n');
return (
<div className="fp-source">
{lines.map((ln, i) => (
<div className="fp-line" key={i}>
<span className="fp-ln">{i + 1}</span>
<code className="fp-code">{ln === '' ? ' ' : ln}</code>
</div>
))}
</div>
);
}

function DiffView({ lines, mode }: { lines: DiffLine[] | null; mode: DiffMode }): JSX.Element {
if (!lines || lines.length === 0) {
return <p className="fp-empty">No diff — this file hasn’t been edited this session.</p>;
}
if (mode === 'inline') {
return (
<div className="fp-diff inline">
{lines.map((l, i) => (
<div className={`fp-dline ${l.kind}`} key={i}>
<span className="fp-ln old">{l.oldNo ?? ''}</span>
<span className="fp-ln new">{l.newNo ?? ''}</span>
<span className="fp-sign">{l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' '}</span>
<code className="fp-code">{l.text === '' ? ' ' : l.text}</code>
</div>
))}
</div>
);
}
// split: old (del+ctx) on the left, new (add+ctx) on the right, row-aligned.
return (
<div className="fp-diff split">
{lines.map((l, i) => (
<div className="fp-drow" key={i}>
<div
className={
'fp-dcell left ' + (l.kind === 'del' ? 'del' : l.kind === 'ctx' ? '' : 'blank')
}
>
{l.kind !== 'add' && (
<>
<span className="fp-ln">{l.oldNo ?? ''}</span>
<code className="fp-code">{l.text === '' ? ' ' : l.text}</code>
</>
)}
</div>
<div
className={
'fp-dcell right ' + (l.kind === 'add' ? 'add' : l.kind === 'ctx' ? '' : 'blank')
}
>
{l.kind !== 'del' && (
<>
<span className="fp-ln">{l.newNo ?? ''}</span>
<code className="fp-code">{l.text === '' ? ' ' : l.text}</code>
</>
)}
</div>
</div>
))}
</div>
);
}

function HistoryView({
entries,
onSelect,
}: {
entries: FileTab['history'];
onSelect: (ts: number) => void;
}): JSX.Element {
if (entries.length === 0) {
return <p className="fp-empty">No version history for this file yet.</p>;
}
return (
<div className="fp-history">
{entries.map((e) => (
<button type="button" className="fp-hist-row" key={e.ts} onClick={() => onSelect(e.ts)}>
<span className="fp-hist-dot" />
<span className="fp-hist-label">{e.label}</span>
<span className="fp-hist-tool">{e.tool}</span>
<span className="fp-hist-ts">{fmtTime(e.ts)}</span>
</button>
))}
</div>
);
}

// ─── 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}`;
}
13 changes: 12 additions & 1 deletion apps/desktop/src/components/InspectorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -40,6 +42,7 @@ export function InspectorPanel({
data,
onCollapse,
focusSection,
onOpenFile,
}: InspectorPanelProps): JSX.Element {
const { usage, costYuan, model, mode, recentFiles, todos } = data;

Expand Down Expand Up @@ -112,7 +115,15 @@ export function InspectorPanel({
) : (
<div className="recent-files">
{recentFiles.map((f) => (
<div className="recent-file" key={f} title={f}>
<div
className="recent-file"
key={f}
title={onOpenFile ? `Open ${f}` : f}
role={onOpenFile ? 'button' : undefined}
tabIndex={onOpenFile ? 0 : undefined}
onClick={onOpenFile ? () => onOpenFile(f) : undefined}
style={onOpenFile ? { cursor: 'pointer' } : undefined}
>
<span className="name">{basename(f)}</span>
<span className="dir">{dirname(f)}</span>
</div>
Expand Down
Loading
Loading