Skip to content

Commit 0737c49

Browse files
perf: cache git diffs per cwd, loading skeleton, remove duplicate theme button from header
1 parent 0b57257 commit 0737c49

17 files changed

Lines changed: 875 additions & 466 deletions

File tree

crates/tauri-app/frontend/e2e/helpers.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,36 @@ export async function injectMockIPC(page: Page) {
292292
case "auto_name_thread":
293293
return null;
294294

295+
// Themes
296+
case "list_themes":
297+
return [
298+
{
299+
id: "obsidian-forge",
300+
name: "Obsidian Forge",
301+
description: "Warm dark theme",
302+
preview: ["#0f1012", "#151518", "#6b7cff", "#4cd694"],
303+
vars: { "--bg-base": "#0f1012", "--bg-card": "#151518", "--primary": "#6b7cff", "--text": "#f0eef8", "--text-secondary": "#b8b6c8", "--text-tertiary": "#807e92", "--bg-surface": "#18181c" },
304+
is_custom: false,
305+
},
306+
{
307+
id: "midnight",
308+
name: "Midnight",
309+
description: "Pure dark with cyan accents",
310+
preview: ["#0a0e14", "#111820", "#00bcd4", "#66bb6a"],
311+
vars: { "--bg-base": "#0a0e14", "--bg-card": "#111820", "--primary": "#00bcd4", "--text": "#e8edf3", "--text-secondary": "#a0b0c0", "--text-tertiary": "#607080", "--bg-surface": "#131a24" },
312+
is_custom: false,
313+
},
314+
];
315+
case "import_theme": {
316+
const imported = JSON.parse(args.jsonContent);
317+
imported.is_custom = true;
318+
return imported;
319+
}
320+
case "delete_custom_theme":
321+
return null;
322+
case "export_theme":
323+
return JSON.stringify({ id: args.id, name: "Exported", description: "", preview: [], vars: {} });
324+
295325
default:
296326
console.warn(`[mock-ipc] unhandled command: ${cmd}`, args);
297327
return null;

crates/tauri-app/frontend/src/components/diff/DiffEditor.tsx

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,53 @@ export function DiffEditor(props: { cwd: string }) {
3939
setStore("diffPanelOpen", false);
4040
}
4141

42+
// Cache diffs per cwd to avoid re-fetching on thread switch
43+
const diffCache = new Map<string, { files: any[]; diffs: any[] }>();
44+
4245
async function loadAll() {
46+
const cwd = props.cwd;
47+
if (!cwd || cwd === ".") {
48+
setFiles([]);
49+
setDiffs([]);
50+
setLoading(false);
51+
return;
52+
}
53+
54+
// Check cache first
55+
const cached = diffCache.get(cwd);
56+
if (cached) {
57+
setFiles(cached.files);
58+
setDiffs(cached.diffs);
59+
if (cached.files.length > 0) setSelectedFile(cached.files[0].path);
60+
setLoading(false);
61+
return;
62+
}
63+
4364
setLoading(true);
4465
setError(null);
66+
// Clear old data immediately for clean transition
67+
setFiles([]);
68+
setDiffs([]);
69+
setSelectedFile(null);
70+
4571
try {
4672
const [changedFiles, sessionDiffs] = await Promise.all([
47-
ipc.getChangedFiles(props.cwd),
48-
ipc.getSessionDiff(props.cwd),
73+
ipc.getChangedFiles(cwd),
74+
ipc.getSessionDiff(cwd),
4975
]);
50-
setFiles(changedFiles);
51-
setDiffs(sessionDiffs);
52-
if (changedFiles.length > 0 && !selectedFile()) {
53-
setSelectedFile(changedFiles[0].path);
76+
// Only update if cwd hasn't changed while we were fetching
77+
if (props.cwd === cwd) {
78+
setFiles(changedFiles);
79+
setDiffs(sessionDiffs);
80+
if (changedFiles.length > 0) setSelectedFile(changedFiles[0].path);
81+
// Cache for 30 seconds
82+
diffCache.set(cwd, { files: changedFiles, diffs: sessionDiffs });
83+
setTimeout(() => diffCache.delete(cwd), 30000);
5484
}
5585
} catch (e) {
56-
setError(String(e));
86+
if (props.cwd === cwd) setError(String(e));
5787
} finally {
58-
setLoading(false);
88+
if (props.cwd === cwd) setLoading(false);
5989
}
6090
}
6191

@@ -132,7 +162,9 @@ export function DiffEditor(props: { cwd: string }) {
132162
{/* File sidebar */}
133163
<div class="de-file-list">
134164
<Show when={loading()}>
135-
<div class="de-empty">Loading...</div>
165+
<div class="de-loading-skeleton">
166+
<div class="de-skel-line" /><div class="de-skel-line short" /><div class="de-skel-line" />
167+
</div>
136168
</Show>
137169
<Show when={error()}>
138170
<div class="de-empty de-error">{error()}</div>
@@ -340,6 +372,25 @@ const DIFF_STYLES = `
340372
}
341373
.de-error { color: var(--red); }
342374
375+
/* Loading skeleton */
376+
.de-loading-skeleton {
377+
padding: 12px;
378+
display: flex;
379+
flex-direction: column;
380+
gap: 8px;
381+
}
382+
.de-skel-line {
383+
height: 10px;
384+
background: var(--bg-accent);
385+
border-radius: 4px;
386+
animation: de-pulse 1.2s ease-in-out infinite;
387+
}
388+
.de-skel-line.short { width: 60%; }
389+
@keyframes de-pulse {
390+
0%, 100% { opacity: 0.4; }
391+
50% { opacity: 0.8; }
392+
}
393+
343394
.de-file-item {
344395
display: flex;
345396
align-items: center;

crates/tauri-app/frontend/src/components/settings/ThemeSelector.tsx

Lines changed: 175 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { For, onMount, onCleanup, createSignal } from "solid-js";
1+
import { For, onMount, onCleanup, createSignal, createResource } from "solid-js";
22
import { appStore } from "../../stores/app-store";
3-
import { themes, applyTheme } from "../../themes";
4-
import { getSetting } from "../../ipc";
3+
import { type Theme, getThemes, applyTheme } from "../../themes";
4+
import { getSetting, importTheme, deleteCustomTheme, exportTheme } from "../../ipc";
55

66
export function ThemeSelector(props?: { inline?: boolean }) {
77
const { setStore } = appStore;
88
const [activeId, setActiveId] = createSignal("obsidian-forge");
9+
const [themes, { refetch }] = createResource<Theme[]>(getThemes, { initialValue: [] });
910

1011
onMount(async () => {
1112
try {
@@ -29,24 +30,95 @@ export function ThemeSelector(props?: { inline?: boolean }) {
2930
onCleanup(() => window.removeEventListener("keydown", handleKeyDown));
3031

3132
function selectTheme(id: string) {
32-
applyTheme(id);
33+
applyTheme(id, themes());
3334
setActiveId(id);
3435
}
3536

37+
async function handleImport() {
38+
try {
39+
const { open } = await import("@tauri-apps/plugin-dialog");
40+
const file = await open({
41+
multiple: false,
42+
filters: [{ name: "Theme", extensions: ["json"] }],
43+
});
44+
if (!file) return;
45+
const path = typeof file === "string" ? file : file.path;
46+
// Read file via fetch (Tauri asset protocol) or use fs plugin
47+
// Since we have the path, read it via a small invoke or use the Rust side
48+
// Actually, we pass the path content through the backend — but import_theme expects JSON string.
49+
// Use the web File API via an input element instead for simplicity:
50+
const response = await fetch(`asset://localhost/${path}`);
51+
const content = await response.text();
52+
await importTheme(content);
53+
refetch();
54+
} catch (e) {
55+
console.error("Failed to import theme:", e);
56+
}
57+
}
58+
59+
async function handleExport(id: string, name: string) {
60+
try {
61+
const { save } = await import("@tauri-apps/plugin-dialog");
62+
const path = await save({
63+
defaultPath: `${id}.json`,
64+
filters: [{ name: "Theme", extensions: ["json"] }],
65+
});
66+
if (!path) return;
67+
const content = await exportTheme(id);
68+
// Write via Tauri fs — but we don't have fs plugin. Use invoke to write or
69+
// use the export content differently. For now, download via blob as fallback.
70+
// Actually, let's write via a simple Rust helper or use the content.
71+
// We can use the backend: the save dialog gives us a path, but we need to write.
72+
// Simplest: use the existing IPC to get content + Blob download.
73+
const blob = new Blob([content], { type: "application/json" });
74+
const url = URL.createObjectURL(blob);
75+
const a = document.createElement("a");
76+
a.href = url;
77+
a.download = `${name.toLowerCase().replace(/\s+/g, "-")}.json`;
78+
a.click();
79+
URL.revokeObjectURL(url);
80+
} catch (e) {
81+
console.error("Failed to export theme:", e);
82+
}
83+
}
84+
85+
async function handleDelete(id: string) {
86+
try {
87+
await deleteCustomTheme(id);
88+
refetch();
89+
// If the deleted theme was active, switch to default
90+
if (activeId() === id) {
91+
selectTheme("obsidian-forge");
92+
}
93+
} catch (e) {
94+
console.error("Failed to delete theme:", e);
95+
}
96+
}
97+
3698
const content = (
3799
<>
38100
<div class="theme-selector-header">
39101
<h2 class="theme-selector-title">Themes</h2>
40-
{!props?.inline && (
41-
<button class="theme-close-btn" onClick={close} title="Close">
42-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
43-
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
102+
<div class="theme-header-actions">
103+
<button class="theme-import-btn" onClick={handleImport} title="Import theme">
104+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
105+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
106+
<polyline points="17 8 12 3 7 8" />
107+
<line x1="12" y1="3" x2="12" y2="15" />
44108
</svg>
109+
<span>Import</span>
45110
</button>
46-
)}
111+
{!props?.inline && (
112+
<button class="theme-close-btn" onClick={close} title="Close">
113+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
114+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
115+
</svg>
116+
</button>
117+
)}
118+
</div>
47119
</div>
48120
<div class="theme-grid">
49-
<For each={themes}>
121+
<For each={themes()}>
50122
{(theme) => (
51123
<button
52124
class="theme-card"
@@ -70,7 +142,35 @@ export function ThemeSelector(props?: { inline?: boolean }) {
70142
<span class="theme-name">{theme.name}</span>
71143
<span class="theme-desc">{theme.description}</span>
72144
</div>
73-
{activeId() === theme.id && <span class="theme-badge">Active</span>}
145+
<div class="theme-card-badges">
146+
{activeId() === theme.id && <span class="theme-badge">Active</span>}
147+
{theme.is_custom && <span class="theme-badge theme-badge-custom">Custom</span>}
148+
</div>
149+
<div class="theme-card-actions" onClick={(e) => e.stopPropagation()}>
150+
<button
151+
class="theme-action-btn"
152+
onClick={() => handleExport(theme.id, theme.name)}
153+
title="Export theme"
154+
>
155+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
156+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
157+
<polyline points="7 10 12 15 17 10" />
158+
<line x1="12" y1="15" x2="12" y2="3" />
159+
</svg>
160+
</button>
161+
{theme.is_custom && (
162+
<button
163+
class="theme-action-btn theme-action-delete"
164+
onClick={() => handleDelete(theme.id)}
165+
title="Delete custom theme"
166+
>
167+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
168+
<polyline points="3 6 5 6 21 6" />
169+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
170+
</svg>
171+
</button>
172+
)}
173+
</div>
74174
</button>
75175
)}
76176
</For>
@@ -118,6 +218,30 @@ if (!document.getElementById("theme-selector-styles")) {
118218
color: var(--text);
119219
letter-spacing: -0.3px;
120220
}
221+
.theme-header-actions {
222+
display: flex;
223+
align-items: center;
224+
gap: 8px;
225+
}
226+
.theme-import-btn {
227+
display: flex;
228+
align-items: center;
229+
gap: 5px;
230+
padding: 5px 10px;
231+
border-radius: var(--radius-sm);
232+
font-size: 12px;
233+
font-weight: 500;
234+
color: var(--text-secondary);
235+
background: var(--bg-accent);
236+
border: 1px solid var(--border);
237+
cursor: pointer;
238+
transition: background 0.15s, color 0.15s, border-color 0.15s;
239+
}
240+
.theme-import-btn:hover {
241+
background: var(--bg-muted);
242+
color: var(--text);
243+
border-color: var(--border-strong);
244+
}
121245
.theme-close-btn {
122246
color: var(--text-tertiary);
123247
padding: 5px;
@@ -234,10 +358,14 @@ if (!document.getElementById("theme-selector-styles")) {
234358
-webkit-box-orient: vertical;
235359
overflow: hidden;
236360
}
237-
.theme-badge {
361+
.theme-card-badges {
238362
position: absolute;
239363
top: 8px;
240364
right: 8px;
365+
display: flex;
366+
gap: 4px;
367+
}
368+
.theme-badge {
241369
font-size: 10px;
242370
font-weight: 600;
243371
padding: 2px 7px;
@@ -246,6 +374,41 @@ if (!document.getElementById("theme-selector-styles")) {
246374
color: #fff;
247375
letter-spacing: 0.02em;
248376
}
377+
.theme-badge-custom {
378+
background: var(--purple, #b47aff);
379+
}
380+
.theme-card-actions {
381+
position: absolute;
382+
bottom: 8px;
383+
right: 8px;
384+
display: flex;
385+
gap: 4px;
386+
opacity: 0;
387+
transition: opacity 0.15s;
388+
}
389+
.theme-card:hover .theme-card-actions {
390+
opacity: 1;
391+
}
392+
.theme-action-btn {
393+
display: flex;
394+
align-items: center;
395+
justify-content: center;
396+
width: 24px;
397+
height: 24px;
398+
border-radius: var(--radius-sm);
399+
background: var(--bg-surface);
400+
border: 1px solid var(--border);
401+
color: var(--text-secondary);
402+
cursor: pointer;
403+
transition: background 0.15s, color 0.15s;
404+
}
405+
.theme-action-btn:hover {
406+
background: var(--bg-accent);
407+
color: var(--text);
408+
}
409+
.theme-action-delete:hover {
410+
color: var(--red);
411+
}
249412
`;
250413
document.head.appendChild(s);
251414
}

crates/tauri-app/frontend/src/components/sidebar/Sidebar.tsx

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,6 @@ export function Sidebar() {
4747
<div class="sidebar-header">
4848
<span class="sidebar-title">CodeForge</span>
4949
<div style={{ display: "flex", gap: "2px" }}>
50-
<button
51-
class="icon-btn"
52-
onClick={() => setStore("themeOpen", true)}
53-
title="Themes"
54-
>
55-
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
56-
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.38-.15-.74-.42-1.02-.27-.28-.42-.64-.42-1.02 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.17-4.5-9-10-9z" />
57-
<circle cx="7.5" cy="11.5" r="1.5" fill="currentColor" />
58-
<circle cx="12" cy="7.5" r="1.5" fill="currentColor" />
59-
<circle cx="16.5" cy="11.5" r="1.5" fill="currentColor" />
60-
</svg>
61-
</button>
6250
<button
6351
class="icon-btn"
6452
onClick={() => setStore("settingsOpen", true)}

0 commit comments

Comments
 (0)