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
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"dependencies": {
"@deepcode/core": "workspace:*",
"@deepcode/shared-ui": "workspace:*",
"@monaco-editor/react": "^4.7.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
Expand All @@ -31,6 +32,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"monaco-editor": "^0.55.1",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
Expand Down
185 changes: 172 additions & 13 deletions apps/desktop/src/screens/FilePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,114 @@
// File panel — right-side Monaco-based viewer with Source / Diff / History tabs.
// File panel — Monaco-backed file viewer with Source / Diff / History tabs.
// Spec: docs/VISUAL_DESIGN.html screens #8 and #11 (M7)
// Milestone: M6-rest skeleton; Monaco wiring lands when the binary dep is installed.

import { useState } from 'react';
import { useEffect, useState } from 'react';
import Editor, { DiffEditor, type Monaco } from '@monaco-editor/react';
import { invoke } from '@tauri-apps/api/core';

interface OpenFile {
path: string;
view: 'source' | 'diff' | 'history';
content?: string;
/** For diff view — the prior content. */
baseContent?: string;
error?: string;
}

type GitLogEntry = { hash: string; date: string; subject: string };

export function FilePanel(): JSX.Element {
const [files, setFiles] = useState<OpenFile[]>([]);
const [active, setActive] = useState<number>(0);
const [history, setHistory] = useState<GitLogEntry[]>([]);

function closeTab(idx: number): void {
setFiles((fs) => fs.filter((_, i) => i !== idx));
setActive((a) => Math.max(0, Math.min(a, files.length - 2)));
}

function switchView(view: OpenFile['view']): void {
async function switchView(view: OpenFile['view']): Promise<void> {
const current = files[active];
if (!current) return;
if (view === 'diff' && current.baseContent === undefined) {
try {
const r = (await invoke('tool_bash', {
input: {
command: `git show HEAD:${shellQuote(current.path)} 2>/dev/null || true`,
timeout_ms: 10_000,
},
})) as { stdout: string };
setFiles((fs) =>
fs.map((f, i) => (i === active ? { ...f, view, baseContent: r.stdout } : f)),
);
} catch {
setFiles((fs) => fs.map((f, i) => (i === active ? { ...f, view } : f)));
}
return;
}
if (view === 'history') {
try {
const r = (await invoke('tool_bash', {
input: {
command: `git log --pretty=format:'%h%x09%ad%x09%s' --date=short -- ${shellQuote(current.path)} 2>/dev/null | head -50`,
timeout_ms: 10_000,
},
})) as { stdout: string };
const entries: GitLogEntry[] = r.stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const [hash = '', date = '', subject = ''] = line.split('\t');
return { hash, date, subject };
});
setHistory(entries);
} catch {
setHistory([]);
}
}
setFiles((fs) => fs.map((f, i) => (i === active ? { ...f, view } : f)));
}

// Open a demo file from the cwd
async function openDemo(): Promise<void> {
const path = 'README.md';
try {
const r = (await invoke('tool_read', { filePath: path })) as { content: string };
setFiles((fs) => [...fs, { path, view: 'source', content: r.content }]);
setActive(files.length);
} catch (err) {
setFiles((fs) => [
...fs,
{ path, view: 'source', error: (err as Error).message ?? String(err) },
]);
}
}

// Configure Monaco for our dark theme on first load
function handleMount(_editor: unknown, monaco: Monaco): void {
monaco.editor.defineTheme('deepcode-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#0e0e10',
'editor.foreground': '#f4f4f5',
'editor.lineHighlightBackground': '#18181b',
'editorLineNumber.foreground': '#52525b',
'editor.selectionBackground': '#27272a',
},
});
monaco.editor.setTheme('deepcode-dark');
}

useEffect(() => {
// History reset on tab switch
setHistory([]);
}, [active]);

if (files.length === 0) {
return (
<div className="flex h-full flex-col">
<div className="border-b border-border p-3 text-xs text-muted">File panel · M7</div>
<div className="border-b border-border p-3 text-xs text-muted">File panel</div>
<div className="flex flex-1 items-center justify-center p-8 text-center text-muted">
<div>
<p>No file open.</p>
Expand All @@ -34,9 +117,9 @@ export function FilePanel(): JSX.Element {
</p>
<button
className="mt-4 rounded bg-accent px-3 py-1 text-xs font-medium text-bg"
onClick={() => setFiles([{ path: 'demo/README.md', view: 'source' }])}
onClick={openDemo}
>
Open a demo tab
Open README.md
</button>
</div>
</div>
Expand All @@ -45,6 +128,8 @@ export function FilePanel(): JSX.Element {
}

const current = files[active]!;
const lang = guessLanguage(current.path);

return (
<div className="flex h-full flex-col">
{/* Tab bar */}
Expand Down Expand Up @@ -76,7 +161,7 @@ export function FilePanel(): JSX.Element {
{(['source', 'diff', 'history'] as const).map((v) => (
<button
key={v}
onClick={() => switchView(v)}
onClick={() => void switchView(v)}
className={
'px-2 py-1 ' +
(current.view === v ? 'text-accent' : 'text-muted hover:text-fg')
Expand All @@ -87,12 +172,86 @@ export function FilePanel(): JSX.Element {
))}
<div className="ml-auto pr-2 text-muted">{current.path}</div>
</div>
{/* Body — Monaco lives here in M7. Stub shows the placeholder. */}
<div className="flex-1 overflow-auto p-3 font-mono text-xs">
<div className="text-muted">
Monaco editor mounts here in M7. {current.view} view of <code>{current.path}</code>.
</div>
{/* Body */}
<div className="flex-1">
{current.error ? (
<div className="p-4 text-sm text-error">Error: {current.error}</div>
) : current.view === 'source' ? (
<Editor
height="100%"
theme="deepcode-dark"
language={lang}
value={current.content ?? ''}
options={{ readOnly: true, minimap: { enabled: false }, fontSize: 13 }}
onMount={handleMount}
/>
) : current.view === 'diff' ? (
<DiffEditor
height="100%"
theme="deepcode-dark"
language={lang}
original={current.baseContent ?? ''}
modified={current.content ?? ''}
options={{ readOnly: true, minimap: { enabled: false }, fontSize: 13 }}
onMount={handleMount}
/>
) : (
<div className="overflow-y-auto p-3 text-xs">
{history.length === 0 ? (
<p className="text-muted">No git history for this file (or not a git repo).</p>
) : (
<table className="w-full text-left font-mono">
<thead className="text-muted">
<tr>
<th className="p-2">Hash</th>
<th className="p-2">Date</th>
<th className="p-2">Subject</th>
</tr>
</thead>
<tbody>
{history.map((e) => (
<tr key={e.hash} className="border-t border-border">
<td className="p-2 text-accent">{e.hash}</td>
<td className="p-2 text-muted">{e.date}</td>
<td className="p-2">{e.subject}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
);
}

function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}

function guessLanguage(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
return (
{
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
json: 'json',
md: 'markdown',
py: 'python',
rs: 'rust',
go: 'go',
rb: 'ruby',
sh: 'shell',
bash: 'shell',
yml: 'yaml',
yaml: 'yaml',
html: 'html',
css: 'css',
sql: 'sql',
toml: 'toml',
}[ext] ?? 'plaintext'
);
}
Loading
Loading