mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
chore: fix all lint and formatting issues (biome check)
- Fix unused imports and variables - Apply biome formatting (template literals, semicolons, line width) - Fix a11y issues (label associations, anchor content) - Fix Node.js import protocol (node:fs, node:path) - Use self.onmessage in web worker Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ea04d34bb4
commit
95c4072ec1
85 changed files with 11649 additions and 1740 deletions
|
|
@ -104,7 +104,9 @@ async function main() {
|
|||
|
||||
if (fs.existsSync(pdfPath)) {
|
||||
fs.copyFileSync(pdfPath, path.join(exampleDir, pdfName));
|
||||
const sizeKb = Math.round(fs.statSync(path.join(exampleDir, pdfName)).size / 1024);
|
||||
const sizeKb = Math.round(
|
||||
fs.statSync(path.join(exampleDir, pdfName)).size / 1024,
|
||||
);
|
||||
console.log(`OK (${sizeKb} KB)`);
|
||||
successCount++;
|
||||
} else {
|
||||
|
|
@ -112,7 +114,8 @@ async function main() {
|
|||
failCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.slice(0, 120) : String(err);
|
||||
const msg =
|
||||
err instanceof Error ? err.message.slice(0, 120) : String(err);
|
||||
console.log(`FAIL: ${msg}`);
|
||||
failCount++;
|
||||
} finally {
|
||||
|
|
@ -125,7 +128,9 @@ async function main() {
|
|||
console.log(
|
||||
"Note: Failed templates may require document classes not installed in your TeX distribution.",
|
||||
);
|
||||
console.log("The gallery will show CSS fallback thumbnails for those templates.");
|
||||
console.log(
|
||||
"The gallery will show CSS fallback thumbnails for those templates.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
{
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
"platforms": ["macOS", "windows", "linux"],
|
||||
"windows": ["main"],
|
||||
"permissions": ["updater:default"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ function WorkspaceWithClaude() {
|
|||
if (!initialized) return;
|
||||
// Delay to let ClaudeChatDrawer mount and register event listeners
|
||||
const timer = setTimeout(() => {
|
||||
const prompt = useClaudeChatStore.getState().consumePendingInitialPrompt();
|
||||
const prompt = useClaudeChatStore
|
||||
.getState()
|
||||
.consumePendingInitialPrompt();
|
||||
if (prompt) {
|
||||
useClaudeChatStore.getState().sendPrompt(prompt);
|
||||
}
|
||||
|
|
@ -91,7 +93,6 @@ export function App({ onReady }: { onReady?: () => void }) {
|
|||
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
|
||||
useKeyboardShortcuts();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.();
|
||||
}, [onReady]);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe("Tauri IPC error message extraction", () => {
|
|||
// This is what Tauri invoke() rejects with for Rust Err(String)
|
||||
const error = "Compilation failed\n\n! Undefined control sequence.";
|
||||
expect(extractErrorMessage(error)).toBe(
|
||||
"Compilation failed\n\n! Undefined control sequence."
|
||||
"Compilation failed\n\n! Undefined control sequence.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,21 +16,84 @@ import { describe, it, expect } from "vitest";
|
|||
|
||||
describe("getFileType logic", () => {
|
||||
// Replicate the classification logic for testing
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".bmp", ".webp"]);
|
||||
const STYLE_EXTENSIONS = new Set([".sty", ".cls", ".bst", ".def", ".cfg", ".fd", ".dtx", ".ins"]);
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".svg",
|
||||
".bmp",
|
||||
".webp",
|
||||
]);
|
||||
const STYLE_EXTENSIONS = new Set([
|
||||
".sty",
|
||||
".cls",
|
||||
".bst",
|
||||
".def",
|
||||
".cfg",
|
||||
".fd",
|
||||
".dtx",
|
||||
".ins",
|
||||
]);
|
||||
const IGNORED_EXTENSIONS = new Set([
|
||||
".aux", ".log", ".out", ".toc", ".lof", ".lot", ".fls",
|
||||
".fdb_latexmk", ".synctex.gz", ".synctex", ".blg", ".bbl",
|
||||
".nav", ".snm", ".vrb", ".run.xml", ".bcf",
|
||||
".aux",
|
||||
".log",
|
||||
".out",
|
||||
".toc",
|
||||
".lof",
|
||||
".lot",
|
||||
".fls",
|
||||
".fdb_latexmk",
|
||||
".synctex.gz",
|
||||
".synctex",
|
||||
".blg",
|
||||
".bbl",
|
||||
".nav",
|
||||
".snm",
|
||||
".vrb",
|
||||
".run.xml",
|
||||
".bcf",
|
||||
// Binary / non-text files
|
||||
".hwp", ".hwpx", ".doc", ".docx", ".xls", ".xlsx", ".xlsm",
|
||||
".ppt", ".pptx", ".accdb", ".mdb",
|
||||
".zip", ".rar", ".7z", ".tar", ".gz",
|
||||
".exe", ".dll", ".so", ".dylib", ".o", ".obj",
|
||||
".bin", ".dat", ".iso", ".dmg", ".msi",
|
||||
".mp3", ".mp4", ".avi", ".mov", ".mkv", ".wav", ".flac",
|
||||
".psd", ".ai", ".sketch", ".fig",
|
||||
".sqlite", ".db",
|
||||
".hwp",
|
||||
".hwpx",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".xlsm",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".accdb",
|
||||
".mdb",
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
".tar",
|
||||
".gz",
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".dylib",
|
||||
".o",
|
||||
".obj",
|
||||
".bin",
|
||||
".dat",
|
||||
".iso",
|
||||
".dmg",
|
||||
".msi",
|
||||
".mp3",
|
||||
".mp4",
|
||||
".avi",
|
||||
".mov",
|
||||
".mkv",
|
||||
".wav",
|
||||
".flac",
|
||||
".psd",
|
||||
".ai",
|
||||
".sketch",
|
||||
".fig",
|
||||
".sqlite",
|
||||
".db",
|
||||
]);
|
||||
|
||||
function getFileType(name: string): string | null {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,12 @@ describe("template-registry", () => {
|
|||
});
|
||||
|
||||
it("returns templates for each category", () => {
|
||||
for (const cat of ["academic", "professional", "creative", "starter"] as const) {
|
||||
for (const cat of [
|
||||
"academic",
|
||||
"professional",
|
||||
"creative",
|
||||
"starter",
|
||||
] as const) {
|
||||
expect(getTemplatesByCategory(cat).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ interface StepInfo {
|
|||
status: StepStatus;
|
||||
}
|
||||
|
||||
function advanceSteps(steps: StepInfo[], targetId: string, order: string[]): StepInfo[] {
|
||||
function advanceSteps(
|
||||
steps: StepInfo[],
|
||||
targetId: string,
|
||||
order: string[],
|
||||
): StepInfo[] {
|
||||
const targetIdx = order.indexOf(targetId);
|
||||
return steps.map((s) => {
|
||||
const thisIdx = order.indexOf(s.id);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useDocumentStore, getCurrentPdfBytes, clearPdfBytesCache, type ProjectFile } from "@/stores/document-store";
|
||||
import {
|
||||
useDocumentStore,
|
||||
getCurrentPdfBytes,
|
||||
clearPdfBytesCache,
|
||||
type ProjectFile,
|
||||
} from "@/stores/document-store";
|
||||
|
||||
// Mock history store
|
||||
vi.mock("@/stores/history-store", () => ({
|
||||
|
|
@ -61,7 +66,9 @@ describe("useDocumentStore", () => {
|
|||
// Validates that getActiveFile() correctly resolves the active file
|
||||
// by confirming insertAtCursor modifies the right file's content
|
||||
useDocumentStore.getState().insertAtCursor("!");
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe("Hello! World");
|
||||
});
|
||||
|
||||
|
|
@ -69,7 +76,9 @@ describe("useDocumentStore", () => {
|
|||
useDocumentStore.setState({ activeFileId: "nonexistent" });
|
||||
useDocumentStore.getState().insertAtCursor("text");
|
||||
// Files should not be modified
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe("Hello World");
|
||||
});
|
||||
});
|
||||
|
|
@ -91,14 +100,18 @@ describe("useDocumentStore", () => {
|
|||
it("inserts at beginning when cursor is at 0", () => {
|
||||
useDocumentStore.setState({ cursorPosition: 0 });
|
||||
useDocumentStore.getState().insertAtCursor(">> ");
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe(">> Hello World");
|
||||
});
|
||||
|
||||
it("inserts at end when cursor is at content length", () => {
|
||||
useDocumentStore.setState({ cursorPosition: 11 }); // "Hello World".length
|
||||
useDocumentStore.getState().insertAtCursor("!");
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe("Hello World!");
|
||||
});
|
||||
|
||||
|
|
@ -126,7 +139,9 @@ describe("useDocumentStore", () => {
|
|||
it("replaces a range of text", () => {
|
||||
// Replace "World" (indices 6-11) with "Universe"
|
||||
useDocumentStore.getState().replaceSelection(6, 11, "Universe");
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe("Hello Universe");
|
||||
expect(file.isDirty).toBe(true);
|
||||
});
|
||||
|
|
@ -138,13 +153,17 @@ describe("useDocumentStore", () => {
|
|||
|
||||
it("can delete text (empty replacement)", () => {
|
||||
useDocumentStore.getState().replaceSelection(5, 11, "");
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe("Hello");
|
||||
});
|
||||
|
||||
it("can insert at a point (start === end)", () => {
|
||||
useDocumentStore.getState().replaceSelection(5, 5, " Beautiful");
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe("Hello Beautiful World");
|
||||
});
|
||||
|
||||
|
|
@ -159,9 +178,13 @@ describe("useDocumentStore", () => {
|
|||
|
||||
describe("findAndReplace", () => {
|
||||
it("replaces first occurrence", () => {
|
||||
const result = useDocumentStore.getState().findAndReplace("World", "Universe");
|
||||
const result = useDocumentStore
|
||||
.getState()
|
||||
.findAndReplace("World", "Universe");
|
||||
expect(result).toBe(true);
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe("Hello Universe");
|
||||
expect(file.isDirty).toBe(true);
|
||||
});
|
||||
|
|
@ -169,7 +192,9 @@ describe("useDocumentStore", () => {
|
|||
it("returns false when find string is not found", () => {
|
||||
const result = useDocumentStore.getState().findAndReplace("xyz", "abc");
|
||||
expect(result).toBe(false);
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.isDirty).toBe(false); // not modified
|
||||
});
|
||||
|
||||
|
|
@ -186,9 +211,13 @@ describe("useDocumentStore", () => {
|
|||
useDocumentStore.setState({
|
||||
files: [makeFile({ content: "price is $10.00" })],
|
||||
});
|
||||
const result = useDocumentStore.getState().findAndReplace("$10.00", "€12.00");
|
||||
const result = useDocumentStore
|
||||
.getState()
|
||||
.findAndReplace("$10.00", "€12.00");
|
||||
expect(result).toBe(true);
|
||||
expect(useDocumentStore.getState().files[0].content).toBe("price is €12.00");
|
||||
expect(useDocumentStore.getState().files[0].content).toBe(
|
||||
"price is €12.00",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing for image files", () => {
|
||||
|
|
@ -203,7 +232,9 @@ describe("useDocumentStore", () => {
|
|||
describe("updateFileContent", () => {
|
||||
it("updates content and marks dirty", () => {
|
||||
useDocumentStore.getState().updateFileContent("main.tex", "New content");
|
||||
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
|
||||
const file = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "main.tex")!;
|
||||
expect(file.content).toBe("New content");
|
||||
expect(file.isDirty).toBe(true);
|
||||
});
|
||||
|
|
@ -212,11 +243,18 @@ describe("useDocumentStore", () => {
|
|||
useDocumentStore.setState({
|
||||
files: [
|
||||
makeFile(),
|
||||
makeFile({ id: "other.tex", name: "other.tex", relativePath: "other.tex", content: "Other" }),
|
||||
makeFile({
|
||||
id: "other.tex",
|
||||
name: "other.tex",
|
||||
relativePath: "other.tex",
|
||||
content: "Other",
|
||||
}),
|
||||
],
|
||||
});
|
||||
useDocumentStore.getState().updateFileContent("main.tex", "Changed");
|
||||
const other = useDocumentStore.getState().files.find((f) => f.id === "other.tex")!;
|
||||
const other = useDocumentStore
|
||||
.getState()
|
||||
.files.find((f) => f.id === "other.tex")!;
|
||||
expect(other.content).toBe("Other");
|
||||
expect(other.isDirty).toBe(false);
|
||||
});
|
||||
|
|
@ -268,7 +306,10 @@ describe("useDocumentStore", () => {
|
|||
files: [makeFile({ isDirty: true, content: "saved content" })],
|
||||
});
|
||||
await useDocumentStore.getState().saveFile("main.tex");
|
||||
expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "saved content");
|
||||
expect(writeTextFile).toHaveBeenCalledWith(
|
||||
"/project/main.tex",
|
||||
"saved content",
|
||||
);
|
||||
expect(useDocumentStore.getState().files[0].isDirty).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -283,7 +324,9 @@ describe("useDocumentStore", () => {
|
|||
|
||||
it("skips saving when content is null", async () => {
|
||||
useDocumentStore.setState({
|
||||
files: [makeFile({ isDirty: true, content: null as unknown as string })],
|
||||
files: [
|
||||
makeFile({ isDirty: true, content: null as unknown as string }),
|
||||
],
|
||||
});
|
||||
await useDocumentStore.getState().saveFile("main.tex");
|
||||
expect(writeTextFile).not.toHaveBeenCalled();
|
||||
|
|
@ -308,19 +351,36 @@ describe("useDocumentStore", () => {
|
|||
useDocumentStore.setState({
|
||||
files: [
|
||||
makeFile({ isDirty: true, content: "dirty content" }),
|
||||
makeFile({ id: "clean.tex", name: "clean.tex", absolutePath: "/project/clean.tex", relativePath: "clean.tex", isDirty: false, content: "clean" }),
|
||||
makeFile({
|
||||
id: "clean.tex",
|
||||
name: "clean.tex",
|
||||
absolutePath: "/project/clean.tex",
|
||||
relativePath: "clean.tex",
|
||||
isDirty: false,
|
||||
content: "clean",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await useDocumentStore.getState().saveAllFiles();
|
||||
expect(writeTextFile).toHaveBeenCalledTimes(1);
|
||||
expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "dirty content");
|
||||
expect(writeTextFile).toHaveBeenCalledWith(
|
||||
"/project/main.tex",
|
||||
"dirty content",
|
||||
);
|
||||
});
|
||||
|
||||
it("saves dirty files with empty string content (regression: empty string is not falsy-skipped)", async () => {
|
||||
useDocumentStore.setState({
|
||||
files: [
|
||||
makeFile({ isDirty: true, content: "" }),
|
||||
makeFile({ id: "slide.tex", name: "slide.tex", absolutePath: "/project/slide.tex", relativePath: "slide.tex", isDirty: true, content: "" }),
|
||||
makeFile({
|
||||
id: "slide.tex",
|
||||
name: "slide.tex",
|
||||
absolutePath: "/project/slide.tex",
|
||||
relativePath: "slide.tex",
|
||||
isDirty: true,
|
||||
content: "",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await useDocumentStore.getState().saveAllFiles();
|
||||
|
|
@ -353,9 +413,11 @@ describe("useDocumentStore", () => {
|
|||
});
|
||||
|
||||
it("setCompileError stores the error string (regression: Tauri string errors must be preserved)", () => {
|
||||
useDocumentStore.getState().setCompileError("Compilation failed\n\n! Undefined control sequence.");
|
||||
useDocumentStore
|
||||
.getState()
|
||||
.setCompileError("Compilation failed\n\n! Undefined control sequence.");
|
||||
expect(useDocumentStore.getState().compileError).toBe(
|
||||
"Compilation failed\n\n! Undefined control sequence."
|
||||
"Compilation failed\n\n! Undefined control sequence.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,7 +39,19 @@ function resetStores() {
|
|||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
tabs: [{ id: "tab-default", title: "New Chat", sessionId: null, messages: [], isStreaming: false, error: null, totalInputTokens: 0, totalOutputTokens: 0, draft: { input: "", pinnedContexts: [] } }],
|
||||
tabs: [
|
||||
{
|
||||
id: "tab-default",
|
||||
title: "New Chat",
|
||||
sessionId: null,
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
draft: { input: "", pinnedContexts: [] },
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-default",
|
||||
_cancelledByUser: false,
|
||||
});
|
||||
|
|
@ -72,7 +84,9 @@ describe("Multi-tab merge triggers", () => {
|
|||
chat.setActiveTab(tabB);
|
||||
|
||||
// Proposed change should still be visible — it's file-scoped, not tab-scoped
|
||||
const change = useProposedChangesStore.getState().getChangeForFile("main.tex");
|
||||
const change = useProposedChangesStore
|
||||
.getState()
|
||||
.getChangeForFile("main.tex");
|
||||
expect(change).toBeDefined();
|
||||
expect(change!.id).toBe("tool-from-tab-a");
|
||||
expect(change!.newContent).toBe("edited by tab A");
|
||||
|
|
@ -162,9 +176,30 @@ describe("Multi-tab merge triggers", () => {
|
|||
it("three sequential edits to the same file all preserve the original baseline", () => {
|
||||
const store = useProposedChangesStore.getState();
|
||||
|
||||
store.addChange({ id: "edit-1", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "baseline", newContent: "v1", toolName: "Edit" });
|
||||
store.addChange({ id: "edit-2", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "v1", newContent: "v2", toolName: "Edit" });
|
||||
store.addChange({ id: "edit-3", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "v2", newContent: "v3", toolName: "MultiEdit" });
|
||||
store.addChange({
|
||||
id: "edit-1",
|
||||
filePath: "doc.tex",
|
||||
absolutePath: "/project/doc.tex",
|
||||
oldContent: "baseline",
|
||||
newContent: "v1",
|
||||
toolName: "Edit",
|
||||
});
|
||||
store.addChange({
|
||||
id: "edit-2",
|
||||
filePath: "doc.tex",
|
||||
absolutePath: "/project/doc.tex",
|
||||
oldContent: "v1",
|
||||
newContent: "v2",
|
||||
toolName: "Edit",
|
||||
});
|
||||
store.addChange({
|
||||
id: "edit-3",
|
||||
filePath: "doc.tex",
|
||||
absolutePath: "/project/doc.tex",
|
||||
oldContent: "v2",
|
||||
newContent: "v3",
|
||||
toolName: "MultiEdit",
|
||||
});
|
||||
|
||||
const { changes } = useProposedChangesStore.getState();
|
||||
expect(changes).toHaveLength(1);
|
||||
|
|
@ -182,13 +217,17 @@ describe("Multi-tab merge triggers", () => {
|
|||
|
||||
// Tab A starts streaming
|
||||
useClaudeChatStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) => t.id === "tab-default" ? { ...t, isStreaming: true } : t),
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === "tab-default" ? { ...t, isStreaming: true } : t,
|
||||
),
|
||||
isStreaming: s.activeTabId === "tab-default",
|
||||
}));
|
||||
|
||||
// Tab B can also be streaming independently
|
||||
useClaudeChatStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) => t.id === tabB ? { ...t, isStreaming: true } : t),
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabB ? { ...t, isStreaming: true } : t,
|
||||
),
|
||||
isStreaming: s.activeTabId === tabB,
|
||||
}));
|
||||
|
||||
|
|
@ -205,7 +244,9 @@ describe("Multi-tab merge triggers", () => {
|
|||
|
||||
// Mark tab A as streaming
|
||||
useClaudeChatStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) => t.id === "tab-default" ? { ...t, isStreaming: true } : t),
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === "tab-default" ? { ...t, isStreaming: true } : t,
|
||||
),
|
||||
}));
|
||||
|
||||
// User is viewing tab B (active), but message is for tab A
|
||||
|
|
@ -221,7 +262,9 @@ describe("Multi-tab merge triggers", () => {
|
|||
const tabBState = state.tabs.find((t) => t.id === tabB)!;
|
||||
|
||||
expect(tabAState.messages).toHaveLength(1);
|
||||
expect(tabAState.messages[0].message?.content?.[0].text).toBe("Hello from stream");
|
||||
expect(tabAState.messages[0].message?.content?.[0].text).toBe(
|
||||
"Hello from stream",
|
||||
);
|
||||
expect(tabBState.messages).toHaveLength(0);
|
||||
|
||||
// Top-level projected messages should reflect the active tab (tab B) — empty
|
||||
|
|
@ -257,7 +300,9 @@ describe("Multi-tab merge triggers", () => {
|
|||
chat._setStreaming("tab-default", false);
|
||||
|
||||
const state = useClaudeChatStore.getState();
|
||||
expect(state.tabs.find((t) => t.id === "tab-default")!.isStreaming).toBe(false);
|
||||
expect(state.tabs.find((t) => t.id === "tab-default")!.isStreaming).toBe(
|
||||
false,
|
||||
);
|
||||
expect(state.tabs.find((t) => t.id === tabB)!.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -327,7 +372,9 @@ describe("Multi-tab merge triggers", () => {
|
|||
chat.setActiveTab(tabB);
|
||||
|
||||
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
|
||||
expect(useProposedChangesStore.getState().changes[0].newContent).toBe("after");
|
||||
expect(useProposedChangesStore.getState().changes[0].newContent).toBe(
|
||||
"after",
|
||||
);
|
||||
});
|
||||
|
||||
it("keepAll clears all changes regardless of which tab is active", () => {
|
||||
|
|
@ -335,12 +382,20 @@ describe("Multi-tab merge triggers", () => {
|
|||
const tabB = chat.createTab();
|
||||
|
||||
useProposedChangesStore.getState().addChange({
|
||||
id: "edit-1", filePath: "main.tex", absolutePath: "/project/main.tex",
|
||||
oldContent: "old-main", newContent: "new-main", toolName: "Edit",
|
||||
id: "edit-1",
|
||||
filePath: "main.tex",
|
||||
absolutePath: "/project/main.tex",
|
||||
oldContent: "old-main",
|
||||
newContent: "new-main",
|
||||
toolName: "Edit",
|
||||
});
|
||||
useProposedChangesStore.getState().addChange({
|
||||
id: "edit-2", filePath: "refs.bib", absolutePath: "/project/refs.bib",
|
||||
oldContent: "old-bib", newContent: "new-bib", toolName: "Write",
|
||||
id: "edit-2",
|
||||
filePath: "refs.bib",
|
||||
absolutePath: "/project/refs.bib",
|
||||
oldContent: "old-bib",
|
||||
newContent: "new-bib",
|
||||
toolName: "Write",
|
||||
});
|
||||
|
||||
chat.setActiveTab(tabB);
|
||||
|
|
@ -368,7 +423,9 @@ describe("Multi-tab merge triggers", () => {
|
|||
chat.closeTab(tabB);
|
||||
|
||||
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
|
||||
expect(useProposedChangesStore.getState().changes[0].id).toBe("from-tab-b");
|
||||
expect(useProposedChangesStore.getState().changes[0].id).toBe(
|
||||
"from-tab-b",
|
||||
);
|
||||
});
|
||||
|
||||
it("creating a new tab does not clear existing proposed changes", () => {
|
||||
|
|
|
|||
|
|
@ -43,12 +43,16 @@ describe("useProjectStore", () => {
|
|||
|
||||
it("extracts name from path correctly", () => {
|
||||
useProjectStore.getState().addRecentProject("/a/b/c/deep-folder");
|
||||
expect(useProjectStore.getState().recentProjects[0].name).toBe("deep-folder");
|
||||
expect(useProjectStore.getState().recentProjects[0].name).toBe(
|
||||
"deep-folder",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses full path as name if no segments", () => {
|
||||
useProjectStore.getState().addRecentProject("standalone");
|
||||
expect(useProjectStore.getState().recentProjects[0].name).toBe("standalone");
|
||||
expect(useProjectStore.getState().recentProjects[0].name).toBe(
|
||||
"standalone",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -111,13 +111,17 @@ describe("useProposedChangesStore", () => {
|
|||
newContent: "b",
|
||||
toolName: "Edit",
|
||||
});
|
||||
const change = useProposedChangesStore.getState().getChangeForFile("main.tex");
|
||||
const change = useProposedChangesStore
|
||||
.getState()
|
||||
.getChangeForFile("main.tex");
|
||||
expect(change).toBeDefined();
|
||||
expect(change!.id).toBe("tool-1");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown file", () => {
|
||||
const change = useProposedChangesStore.getState().getChangeForFile("nonexistent.tex");
|
||||
const change = useProposedChangesStore
|
||||
.getState()
|
||||
.getChangeForFile("nonexistent.tex");
|
||||
expect(change).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ describe("useTemplateStore", () => {
|
|||
useTemplateStore.getState().setSelectedCategory("academic");
|
||||
const { filteredTemplates } = useTemplateStore.getState();
|
||||
expect(filteredTemplates.length).toBeGreaterThan(0);
|
||||
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(true);
|
||||
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears category filter with null", () => {
|
||||
|
|
@ -53,7 +55,9 @@ describe("useTemplateStore", () => {
|
|||
useTemplateStore.getState().setSelectedCategory("academic");
|
||||
const { filteredTemplates } = useTemplateStore.getState();
|
||||
expect(filteredTemplates.length).toBeGreaterThan(0);
|
||||
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(true);
|
||||
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ function storeKey(collectionKey: string | null): string {
|
|||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_\-\s]/g, "").replace(/\s+/g, "-").toLowerCase();
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9_\-\s]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseBibEntries(content: string): Map<string, string> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Validates tauri.conf.json CSP (Content Security Policy) configuration.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import { ComponentPropsWithRef, forwardRef } from "react";
|
||||
import { Slottable } from "@radix-ui/react-slot";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,38 @@
|
|||
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowUpIcon, SquareIcon, XIcon, FileTextIcon, FileCodeIcon, FileIcon, ImageIcon, FileSpreadsheetIcon, PaperclipIcon, ZapIcon, CheckIcon, ChevronDownIcon, SparklesIcon, RabbitIcon, LayersIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
SquareIcon,
|
||||
XIcon,
|
||||
FileTextIcon,
|
||||
FileCodeIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
FileSpreadsheetIcon,
|
||||
PaperclipIcon,
|
||||
ZapIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
SparklesIcon,
|
||||
RabbitIcon,
|
||||
LayersIcon,
|
||||
} from "lucide-react";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs";
|
||||
import { join } from "@tauri-apps/api/path";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useClaudeChatStore, offsetToLineCol } from "@/stores/claude-chat-store";
|
||||
import {
|
||||
useClaudeChatStore,
|
||||
offsetToLineCol,
|
||||
} from "@/stores/claude-chat-store";
|
||||
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
||||
import { getUniqueTargetName } from "@/lib/tauri/fs";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
|
@ -19,17 +46,23 @@ const log = createLogger("chat-composer");
|
|||
export type { SlashCommand };
|
||||
|
||||
interface PinnedContext {
|
||||
label: string; // @file:line:col-line:col
|
||||
label: string; // @file:line:col-line:col
|
||||
filePath: string;
|
||||
selectedText: string;
|
||||
imageDataUrl?: string; // thumbnail for captured images
|
||||
}
|
||||
|
||||
function getFileIcon(file: ProjectFile) {
|
||||
if (file.type === "image") return <ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (file.type === "pdf") return <FileSpreadsheetIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (file.type === "style") return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (file.type === "other") return <FileIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (file.type === "image")
|
||||
return <ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (file.type === "pdf")
|
||||
return (
|
||||
<FileSpreadsheetIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
);
|
||||
if (file.type === "style")
|
||||
return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (file.type === "other")
|
||||
return <FileIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
return <FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +82,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
||||
const modelPickerRef = useRef<HTMLDivElement>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [pickerPos, setPickerPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
|
||||
const [pickerPos, setPickerPos] = useState<{ left: number; bottom: number }>({
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
});
|
||||
|
||||
// Recalculate popup position when it opens
|
||||
useLayoutEffect(() => {
|
||||
|
|
@ -98,7 +134,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
prevTabIdRef.current = activeTabId;
|
||||
|
||||
// Restore draft from the new active tab
|
||||
const tab = useClaudeChatStore.getState().tabs.find((t) => t.id === activeTabId);
|
||||
const tab = useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((t) => t.id === activeTabId);
|
||||
const draft = tab?.draft;
|
||||
setInput(draft?.input ?? "");
|
||||
setPinnedContexts(draft?.pinnedContexts ?? []);
|
||||
|
|
@ -121,7 +159,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
|
||||
// Consume pending attachments from external sources (e.g. PDF capture)
|
||||
const pendingAttachments = useClaudeChatStore((s) => s.pendingAttachments);
|
||||
const consumePendingAttachments = useClaudeChatStore((s) => s.consumePendingAttachments);
|
||||
const consumePendingAttachments = useClaudeChatStore(
|
||||
(s) => s.consumePendingAttachments,
|
||||
);
|
||||
|
||||
// Focus textarea when the drawer opens
|
||||
const prevOpenRef = useRef(false);
|
||||
|
|
@ -161,13 +201,18 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
if (!file?.content) return;
|
||||
// Replace any existing selection-based context (keep file contexts)
|
||||
setPinnedContexts((prev) => {
|
||||
const filtered = prev.filter((c) => !c.label.includes(":") || c.label.startsWith("@attachments/"));
|
||||
const filtered = prev.filter(
|
||||
(c) => !c.label.includes(":") || c.label.startsWith("@attachments/"),
|
||||
);
|
||||
return [
|
||||
...filtered,
|
||||
{
|
||||
label: currentContextLabel,
|
||||
filePath: file.relativePath,
|
||||
selectedText: file.content!.slice(selectionRange.start, selectionRange.end),
|
||||
selectedText: file.content!.slice(
|
||||
selectionRange.start,
|
||||
selectionRange.end,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
@ -181,7 +226,11 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
}
|
||||
const q = mentionQuery.toLowerCase();
|
||||
const matched = files
|
||||
.filter((f) => f.relativePath.toLowerCase().includes(q) || f.name.toLowerCase().includes(q))
|
||||
.filter(
|
||||
(f) =>
|
||||
f.relativePath.toLowerCase().includes(q) ||
|
||||
f.name.toLowerCase().includes(q),
|
||||
)
|
||||
.slice(0, 8);
|
||||
setMentionFiles(matched);
|
||||
setMentionIndex(0);
|
||||
|
|
@ -197,35 +246,42 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
.catch(() => setSlashCommands([]));
|
||||
}, [slashQuery !== null, projectRoot]);
|
||||
|
||||
const selectMention = useCallback((file: ProjectFile) => {
|
||||
// Replace @query with empty and pin the file as context
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
// Find the @ position before cursor
|
||||
const textBefore = input.slice(0, cursorPos);
|
||||
const atIndex = textBefore.lastIndexOf("@");
|
||||
if (atIndex === -1) return;
|
||||
const newInput = input.slice(0, atIndex) + input.slice(cursorPos);
|
||||
setInput(newInput);
|
||||
setMentionQuery(null);
|
||||
const selectMention = useCallback(
|
||||
(file: ProjectFile) => {
|
||||
// Replace @query with empty and pin the file as context
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
// Find the @ position before cursor
|
||||
const textBefore = input.slice(0, cursorPos);
|
||||
const atIndex = textBefore.lastIndexOf("@");
|
||||
if (atIndex === -1) return;
|
||||
const newInput = input.slice(0, atIndex) + input.slice(cursorPos);
|
||||
setInput(newInput);
|
||||
setMentionQuery(null);
|
||||
|
||||
// Pin the whole file as context
|
||||
const isTextFile = file.type === "tex" || file.type === "bib" || file.type === "style" || file.type === "other";
|
||||
setPinnedContexts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
label: `@${file.relativePath}`,
|
||||
filePath: file.relativePath,
|
||||
selectedText: isTextFile
|
||||
? (file.content ?? "")
|
||||
: `[Referenced file: ${file.relativePath} (${file.type} file)]`,
|
||||
},
|
||||
]);
|
||||
// Pin the whole file as context
|
||||
const isTextFile =
|
||||
file.type === "tex" ||
|
||||
file.type === "bib" ||
|
||||
file.type === "style" ||
|
||||
file.type === "other";
|
||||
setPinnedContexts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
label: `@${file.relativePath}`,
|
||||
filePath: file.relativePath,
|
||||
selectedText: isTextFile
|
||||
? (file.content ?? "")
|
||||
: `[Referenced file: ${file.relativePath} (${file.type} file)]`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Refocus textarea
|
||||
setTimeout(() => textarea.focus(), 0);
|
||||
}, [input]);
|
||||
// Refocus textarea
|
||||
setTimeout(() => textarea.focus(), 0);
|
||||
},
|
||||
[input],
|
||||
);
|
||||
|
||||
const selectSlashCommand = useCallback((command: SlashCommand) => {
|
||||
// Insert command syntax into input (opcode-style)
|
||||
|
|
@ -245,14 +301,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
textarea.selectionStart = textarea.selectionEnd = newInput.length;
|
||||
// Auto-resize
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 160) + "px";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 160)}px`;
|
||||
}
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
// Handle file drops — guard against duplicate calls from stale HMR listeners
|
||||
const isProcessingDropRef = useRef(false);
|
||||
const handleFileDropRef = useRef<(paths: string[]) => Promise<void>>(async () => {});
|
||||
const handleFileDropRef = useRef<(paths: string[]) => Promise<void>>(
|
||||
async () => {},
|
||||
);
|
||||
handleFileDropRef.current = async (paths: string[]) => {
|
||||
if (!projectRoot || paths.length === 0) return;
|
||||
if (isProcessingDropRef.current) return;
|
||||
|
|
@ -267,10 +325,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
const newContexts: PinnedContext[] = [];
|
||||
|
||||
for (const relativePath of importedPaths) {
|
||||
const imported = storeFiles.find((f) => f.relativePath === relativePath);
|
||||
const imported = storeFiles.find(
|
||||
(f) => f.relativePath === relativePath,
|
||||
);
|
||||
|
||||
if (imported) {
|
||||
const isText = imported.type === "tex" || imported.type === "bib" || imported.type === "style" || imported.type === "other";
|
||||
const isText =
|
||||
imported.type === "tex" ||
|
||||
imported.type === "bib" ||
|
||||
imported.type === "style" ||
|
||||
imported.type === "other";
|
||||
newContexts.push({
|
||||
label: `@${relativePath}`,
|
||||
filePath: relativePath,
|
||||
|
|
@ -292,7 +356,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
setPinnedContexts((prev) => {
|
||||
// Deduplicate by label
|
||||
const existingLabels = new Set(prev.map((c) => c.label));
|
||||
const unique = newContexts.filter((c) => !existingLabels.has(c.label));
|
||||
const unique = newContexts.filter(
|
||||
(c) => !existingLabels.has(c.label),
|
||||
);
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
}
|
||||
|
|
@ -348,7 +414,8 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const clipboardFiles = e.clipboardData?.files;
|
||||
if (!clipboardFiles || clipboardFiles.length === 0 || !projectRoot) return;
|
||||
if (!clipboardFiles || clipboardFiles.length === 0 || !projectRoot)
|
||||
return;
|
||||
|
||||
// Check if there are actual file items (not just text)
|
||||
const fileItems = Array.from(clipboardFiles);
|
||||
|
|
@ -385,7 +452,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
|
||||
// Determine if it's a text file
|
||||
const isText = file.type.startsWith("text/");
|
||||
const content = isText ? await file.text() : `[Attached file: ${uniqueName} (${file.type})]`;
|
||||
const content = isText
|
||||
? await file.text()
|
||||
: `[Attached file: ${uniqueName} (${file.type})]`;
|
||||
|
||||
newContexts.push({
|
||||
label: `@${uniqueName}`,
|
||||
|
|
@ -393,7 +462,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
selectedText: content,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("Failed to save pasted file", { fileName, error: String(err) });
|
||||
log.error("Failed to save pasted file", {
|
||||
fileName,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -403,7 +475,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
|
||||
setPinnedContexts((prev) => {
|
||||
const existingLabels = new Set(prev.map((c) => c.label));
|
||||
const unique = newContexts.filter((c) => !existingLabels.has(c.label));
|
||||
const unique = newContexts.filter(
|
||||
(c) => !existingLabels.has(c.label),
|
||||
);
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
}
|
||||
|
|
@ -440,7 +514,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
// Send with pinned context override
|
||||
if (pinnedContexts.length > 0) {
|
||||
const combinedLabel = pinnedContexts.map((c) => c.label).join(", ");
|
||||
const combinedText = pinnedContexts.map((c) => c.selectedText).join("\n\n---\n\n");
|
||||
const combinedText = pinnedContexts
|
||||
.map((c) => c.selectedText)
|
||||
.join("\n\n---\n\n");
|
||||
sendPrompt(finalPrompt, {
|
||||
label: combinedLabel,
|
||||
filePath: pinnedContexts[0].filePath,
|
||||
|
|
@ -462,7 +538,13 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
// Slash command picker is open — let the picker handle keyboard events
|
||||
// (it uses window.addEventListener for ArrowUp/Down, Enter, Tab, Escape)
|
||||
if (slashQuery !== null) {
|
||||
if (e.key === "Enter" || e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab" || e.key === "Escape") {
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === "ArrowDown" ||
|
||||
e.key === "ArrowUp" ||
|
||||
e.key === "Tab" ||
|
||||
e.key === "Escape"
|
||||
) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
|
@ -502,7 +584,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
setPinnedContexts((prev) => prev.slice(0, -1));
|
||||
}
|
||||
},
|
||||
[handleSend, pinnedContexts, input, mentionQuery, mentionFiles, mentionIndex, selectMention, slashQuery],
|
||||
[
|
||||
handleSend,
|
||||
pinnedContexts,
|
||||
input,
|
||||
mentionQuery,
|
||||
mentionFiles,
|
||||
mentionIndex,
|
||||
selectMention,
|
||||
slashQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(
|
||||
|
|
@ -539,7 +630,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
// Auto-resize
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -558,8 +649,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
modelPickerRef.current && !modelPickerRef.current.contains(target) &&
|
||||
modelButtonRef.current && !modelButtonRef.current.contains(target)
|
||||
modelPickerRef.current &&
|
||||
!modelPickerRef.current.contains(target) &&
|
||||
modelButtonRef.current &&
|
||||
!modelButtonRef.current.contains(target)
|
||||
) {
|
||||
setModelPickerOpen(false);
|
||||
}
|
||||
|
|
@ -584,105 +677,149 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
)}
|
||||
|
||||
{/* Model picker popup — portal to body to escape all stacking contexts */}
|
||||
{modelPickerOpen && createPortal(
|
||||
<div
|
||||
ref={modelPickerRef}
|
||||
className="fixed w-64 rounded-lg border border-border bg-background shadow-lg"
|
||||
style={{ left: pickerPos.left, bottom: pickerPos.bottom, zIndex: 9999 }}
|
||||
>
|
||||
{/* Models */}
|
||||
<div className="p-1">
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">Model</div>
|
||||
{([
|
||||
{ id: "sonnet" as const, name: "Sonnet", desc: "Fast, efficient for most tasks", icon: <ZapIcon className="size-3.5" /> },
|
||||
{ id: "opus" as const, name: "Opus", desc: "Most capable, complex reasoning", icon: <SparklesIcon className="size-3.5" /> },
|
||||
{ id: "haiku" as const, name: "Haiku", desc: "Fastest, simple tasks", icon: <RabbitIcon className="size-3.5" /> },
|
||||
{ id: "opusplan" as const, name: "OpusPlan", desc: "Opus for planning, Sonnet for execution", icon: <LayersIcon className="size-3.5" /> },
|
||||
]).map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
selectedModel === m.id ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setSelectedModel(m.id)}
|
||||
>
|
||||
{m.icon}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-xs">{m.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{m.desc}</div>
|
||||
</div>
|
||||
{selectedModel === m.id && <CheckIcon className="size-3 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Effort level */}
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between px-1 mb-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Effort</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{effortLevel === "low" ? "Low" : effortLevel === "medium" ? "Medium" : "High"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["low", "medium", "high"] as const).map((level) => (
|
||||
{modelPickerOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={modelPickerRef}
|
||||
className="fixed w-64 rounded-lg border border-border bg-background shadow-lg"
|
||||
style={{
|
||||
left: pickerPos.left,
|
||||
bottom: pickerPos.bottom,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{/* Models */}
|
||||
<div className="p-1">
|
||||
<div className="px-2 py-1 font-medium text-muted-foreground text-xs">
|
||||
Model
|
||||
</div>
|
||||
{[
|
||||
{
|
||||
id: "sonnet" as const,
|
||||
name: "Sonnet",
|
||||
desc: "Fast, efficient for most tasks",
|
||||
icon: <ZapIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "opus" as const,
|
||||
name: "Opus",
|
||||
desc: "Most capable, complex reasoning",
|
||||
icon: <SparklesIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "haiku" as const,
|
||||
name: "Haiku",
|
||||
desc: "Fastest, simple tasks",
|
||||
icon: <RabbitIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "opusplan" as const,
|
||||
name: "OpusPlan",
|
||||
desc: "Opus for planning, Sonnet for execution",
|
||||
icon: <LayersIcon className="size-3.5" />,
|
||||
},
|
||||
].map((m) => (
|
||||
<button
|
||||
key={level}
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex-1 rounded-md py-1 text-center text-xs font-medium transition-colors",
|
||||
effortLevel === level
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
selectedModel === m.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setEffortLevel(level)}
|
||||
onClick={() => setSelectedModel(m.id)}
|
||||
>
|
||||
{level === "low" ? "L" : level === "medium" ? "M" : "H"}
|
||||
{m.icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-xs">{m.name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{m.desc}
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel === m.id && (
|
||||
<CheckIcon className="size-3 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
<div className="border-border border-t" />
|
||||
|
||||
{/* Effort level */}
|
||||
<div className="p-2">
|
||||
<div className="mb-1.5 flex items-center justify-between px-1">
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
Effort
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{effortLevel === "low"
|
||||
? "Low"
|
||||
: effortLevel === "medium"
|
||||
? "Medium"
|
||||
: "High"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["low", "medium", "high"] as const).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
className={cn(
|
||||
"flex-1 rounded-md py-1 text-center font-medium text-xs transition-colors",
|
||||
effortLevel === level
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
onClick={() => setEffortLevel(level)}
|
||||
>
|
||||
{level === "low" ? "L" : level === "medium" ? "M" : "H"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* @ mention dropdown */}
|
||||
{slashQuery === null && mentionQuery !== null && mentionFiles.length > 0 && (
|
||||
<div
|
||||
ref={mentionRef}
|
||||
className="absolute bottom-full left-3 right-3 mb-1 max-h-48 overflow-y-auto rounded-lg border border-border bg-background shadow-lg"
|
||||
>
|
||||
{mentionFiles.map((file, i) => {
|
||||
const parts = file.relativePath.split("/");
|
||||
const fileName = parts.pop()!;
|
||||
const dirPath = parts.length > 0 ? parts.join("/") + "/" : "";
|
||||
return (
|
||||
<button
|
||||
key={file.id}
|
||||
data-active={i === mentionIndex}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors",
|
||||
i === mentionIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent textarea blur
|
||||
selectMention(file);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
{getFileIcon(file)}
|
||||
<span className="truncate font-mono text-sm">{fileName}</span>
|
||||
{dirPath && (
|
||||
<span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">{dirPath}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{slashQuery === null &&
|
||||
mentionQuery !== null &&
|
||||
mentionFiles.length > 0 && (
|
||||
<div
|
||||
ref={mentionRef}
|
||||
className="absolute right-3 bottom-full left-3 mb-1 max-h-48 overflow-y-auto rounded-lg border border-border bg-background shadow-lg"
|
||||
>
|
||||
{mentionFiles.map((file, i) => {
|
||||
const parts = file.relativePath.split("/");
|
||||
const fileName = parts.pop()!;
|
||||
const dirPath = parts.length > 0 ? `${parts.join("/")}/` : "";
|
||||
return (
|
||||
<button
|
||||
key={file.id}
|
||||
data-active={i === mentionIndex}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors",
|
||||
i === mentionIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent textarea blur
|
||||
selectMention(file);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
{getFileIcon(file)}
|
||||
<span className="truncate font-mono text-sm">{fileName}</span>
|
||||
{dirPath && (
|
||||
<span className="ml-auto shrink-0 font-mono text-muted-foreground text-xs">
|
||||
{dirPath}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -706,7 +843,11 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
/>
|
||||
<button
|
||||
aria-label="Remove attachment"
|
||||
onClick={() => setPinnedContexts((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
onClick={() =>
|
||||
setPinnedContexts((prev) =>
|
||||
prev.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
className="absolute top-0.5 right-0.5 rounded-full bg-background/80 p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
|
|
@ -715,12 +856,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
) : (
|
||||
<span
|
||||
key={`${ctx.label}-${i}`}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground"
|
||||
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 font-mono text-muted-foreground text-xs"
|
||||
>
|
||||
{ctx.label}
|
||||
<button
|
||||
aria-label="Remove context"
|
||||
onClick={() => setPinnedContexts((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
onClick={() =>
|
||||
setPinnedContexts((prev) =>
|
||||
prev.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
|
|
@ -732,7 +877,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
)}
|
||||
|
||||
{isDragOver ? (
|
||||
<div className="flex min-h-10 items-center justify-center px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="flex min-h-10 items-center justify-center px-4 py-3 text-muted-foreground text-sm">
|
||||
<PaperclipIcon className="mr-2 size-4" />
|
||||
Drop files to attach
|
||||
</div>
|
||||
|
|
@ -756,13 +901,23 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
ref={modelButtonRef}
|
||||
type="button"
|
||||
onClick={() => setModelPickerOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span>
|
||||
{selectedModel === "sonnet" ? "Sonnet" : selectedModel === "opus" ? "Opus" : selectedModel === "haiku" ? "Haiku" : "OpusPlan"}
|
||||
{selectedModel === "sonnet"
|
||||
? "Sonnet"
|
||||
: selectedModel === "opus"
|
||||
? "Opus"
|
||||
: selectedModel === "haiku"
|
||||
? "Haiku"
|
||||
: "OpusPlan"}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">
|
||||
{effortLevel === "low" ? "L" : effortLevel === "medium" ? "M" : "H"}
|
||||
{effortLevel === "low"
|
||||
? "L"
|
||||
: effortLevel === "medium"
|
||||
? "M"
|
||||
: "H"}
|
||||
</span>
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { useClaudeChatStore, type ClaudeStreamMessage, type ContentBlock } from "@/stores/claude-chat-store";
|
||||
import {
|
||||
useClaudeChatStore,
|
||||
type ClaudeStreamMessage,
|
||||
type ContentBlock,
|
||||
} from "@/stores/claude-chat-store";
|
||||
import { MarkdownRenderer } from "./markdown-renderer";
|
||||
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
|
||||
|
||||
|
|
@ -22,14 +26,25 @@ const StreamingIndicator: FC = memo(() => {
|
|||
return (
|
||||
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
|
||||
<div className="flex gap-0.5">
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "0ms" }} />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "150ms" }} />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "300ms" }} />
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
Thinking...
|
||||
{elapsed >= 3 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground/60">{elapsed}s</span>
|
||||
<span className="ml-1 text-muted-foreground/60 text-xs">
|
||||
{elapsed}s
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -76,11 +91,16 @@ export const ChatMessages: FC = () => {
|
|||
|
||||
return messages.filter((msg) => {
|
||||
if (msg.type === "system" && msg.subtype === "init") return false;
|
||||
if (msg.type !== "user" && msg.type !== "assistant" && msg.type !== "result") return false;
|
||||
if (
|
||||
msg.type !== "user" &&
|
||||
msg.type !== "assistant" &&
|
||||
msg.type !== "result"
|
||||
)
|
||||
return false;
|
||||
if (msg.type === "user" && msg.message?.content) {
|
||||
if (Array.isArray(msg.message.content)) {
|
||||
const hasOnlyToolResults = msg.message.content.every(
|
||||
(b: any) => b.type === "tool_result"
|
||||
(b: any) => b.type === "tool_result",
|
||||
);
|
||||
if (hasOnlyToolResults) return false;
|
||||
}
|
||||
|
|
@ -113,7 +133,8 @@ export const ChatMessages: FC = () => {
|
|||
const handleScroll = () => {
|
||||
if (!viewportRef.current) return;
|
||||
const el = viewportRef.current;
|
||||
const isAtBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
|
||||
const isAtBottom =
|
||||
Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
|
||||
if (!isAtBottom) {
|
||||
userHasScrolledRef.current = true;
|
||||
shouldAutoScrollRef.current = false;
|
||||
|
|
@ -136,11 +157,7 @@ export const ChatMessages: FC = () => {
|
|||
)}
|
||||
|
||||
{displayMessages.map((msg, idx) => (
|
||||
<MessageBubble
|
||||
key={idx}
|
||||
message={msg}
|
||||
toolResultMap={toolResultMap}
|
||||
/>
|
||||
<MessageBubble key={idx} message={msg} toolResultMap={toolResultMap} />
|
||||
))}
|
||||
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
|
|
@ -191,13 +208,13 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
// Lint multi: "[Lint errors in FILE]\n- FILE:LINE — MSG\n...\n\nPrompt"
|
||||
// Compile: "[Compilation errors]\n- error1\n- error2\n...\n\nPrompt"
|
||||
const lintSingleMatch = bodyText.match(
|
||||
/^\[Lint error in ([^\]]+)\]\n\[Error: ([^\]]+)\]\n\n([\s\S]*)$/
|
||||
/^\[Lint error in ([^\]]+)\]\n\[Error: ([^\]]+)\]\n\n([\s\S]*)$/,
|
||||
);
|
||||
const lintMultiMatch = bodyText.match(
|
||||
/^\[Lint errors in ([^\]]+)\]\n((?:- .+\n?)+)\n([\s\S]*)$/
|
||||
/^\[Lint errors in ([^\]]+)\]\n((?:- .+\n?)+)\n([\s\S]*)$/,
|
||||
);
|
||||
const compileErrorMatch = bodyText.match(
|
||||
/^\[Compilation errors\]\n((?:- .+\n?)+)\n([\s\S]*)$/
|
||||
/^\[Compilation errors\]\n((?:- .+\n?)+)\n([\s\S]*)$/,
|
||||
);
|
||||
|
||||
// Shared error block renderer
|
||||
|
|
@ -209,14 +226,18 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
<div className="flex w-full flex-col items-end py-1.5">
|
||||
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-2 text-foreground text-sm">
|
||||
<div className="mb-2 rounded-lg border border-red-500/20 bg-red-500/10 px-2.5 py-2">
|
||||
<div className="mb-1.5 text-xs font-medium text-red-400">{title}</div>
|
||||
<div className="mb-1.5 font-medium text-red-400 text-xs">{title}</div>
|
||||
<div className="space-y-1">
|
||||
{errors.map((e, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<AlertCircleIcon className="mt-0.5 size-3 shrink-0 text-red-400/70" />
|
||||
<span className="flex-1 text-xs text-foreground/80">{e.message}</span>
|
||||
<span className="flex-1 text-foreground/80 text-xs">
|
||||
{e.message}
|
||||
</span>
|
||||
{e.location && (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">{e.location}</span>
|
||||
<span className="shrink-0 font-mono text-muted-foreground text-xs">
|
||||
{e.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -238,18 +259,26 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
|
||||
if (lintMultiMatch) {
|
||||
const [, fileName, errorLines, prompt] = lintMultiMatch;
|
||||
const errors = errorLines.trim().split("\n").map((line) => {
|
||||
const m = line.match(/^- (.+?):(\d+) — (.+)$/);
|
||||
return m ? { message: m[3], location: `${m[1]}:${m[2]}` } : { message: line.replace(/^- /, "") };
|
||||
});
|
||||
const errors = errorLines
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const m = line.match(/^- (.+?):(\d+) — (.+)$/);
|
||||
return m
|
||||
? { message: m[3], location: `${m[1]}:${m[2]}` }
|
||||
: { message: line.replace(/^- /, "") };
|
||||
});
|
||||
return renderErrorBlock(`Lint Errors — ${fileName}`, errors, prompt);
|
||||
}
|
||||
|
||||
if (compileErrorMatch) {
|
||||
const [, errorLines, prompt] = compileErrorMatch;
|
||||
const errors = errorLines.trim().split("\n").map((line) => ({
|
||||
message: line.replace(/^- /, ""),
|
||||
}));
|
||||
const errors = errorLines
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => ({
|
||||
message: line.replace(/^- /, ""),
|
||||
}));
|
||||
return renderErrorBlock(
|
||||
`Compilation ${errors.length === 1 ? "Error" : "Errors"}`,
|
||||
errors,
|
||||
|
|
@ -261,7 +290,7 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
<div className="flex w-full flex-col items-end py-1.5">
|
||||
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-1.5 text-foreground text-sm">
|
||||
{contextLabel && (
|
||||
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
|
||||
{contextLabel}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -288,7 +317,7 @@ const AssistantMessage: FC<{
|
|||
(block) =>
|
||||
(block.type === "text" && block.text) ||
|
||||
(block.type === "thinking" && block.thinking) ||
|
||||
(block.type === "tool_use" && block.id)
|
||||
(block.type === "tool_use" && block.id),
|
||||
);
|
||||
|
||||
if (!hasRenderableContent) return null;
|
||||
|
|
@ -317,13 +346,7 @@ const AssistantMessage: FC<{
|
|||
}
|
||||
if (block.type === "tool_use" && block.id) {
|
||||
const result = toolResultMap.get(block.id);
|
||||
return (
|
||||
<ToolWidget
|
||||
key={idx}
|
||||
toolUse={block}
|
||||
toolResult={result}
|
||||
/>
|
||||
);
|
||||
return <ToolWidget key={idx} toolUse={block} toolResult={result} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,14 @@ export function ChatTabBar() {
|
|||
|
||||
// Scroll active tab into view when it changes
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current?.querySelector(`[data-tab-id="${activeTabId}"]`);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
||||
const el = scrollRef.current?.querySelector(
|
||||
`[data-tab-id="${activeTabId}"]`,
|
||||
);
|
||||
el?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}, [activeTabId]);
|
||||
|
||||
// Keyboard shortcuts: Ctrl+Tab / Ctrl+Shift+Tab to switch tabs, Ctrl+T new, Ctrl+W close
|
||||
|
|
@ -72,10 +78,10 @@ export function ChatTabBar() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b border-border">
|
||||
<div className="flex items-center border-border border-b">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex min-w-0 flex-1 items-center overflow-x-auto scrollbar-none"
|
||||
className="scrollbar-none flex min-w-0 flex-1 items-center overflow-x-auto"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export function ClaudeChatDrawer() {
|
|||
// Initialize event listeners for Claude streaming
|
||||
useClaudeEvents();
|
||||
|
||||
const anyStreaming = useClaudeChatStore((s) => s.tabs.some((t) => t.isStreaming));
|
||||
const anyStreaming = useClaudeChatStore((s) =>
|
||||
s.tabs.some((t) => t.isStreaming),
|
||||
);
|
||||
const error = useClaudeChatStore((s) => s.error);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
|
@ -50,41 +52,44 @@ export function ClaudeChatDrawer() {
|
|||
}
|
||||
}, [anyStreaming, isOpen, pendingAttachments]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (isExpanded) return;
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isExpanded) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
hasDraggedRef.current = false;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
hasDraggedRef.current = false;
|
||||
|
||||
const startY = e.clientY;
|
||||
const startHeight = heightRef.current;
|
||||
const startY = e.clientY;
|
||||
const startHeight = heightRef.current;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
hasDraggedRef.current = true;
|
||||
const parent = containerRef.current?.parentElement;
|
||||
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
|
||||
const delta = startY - e.clientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(startHeight + delta, MIN_HEIGHT),
|
||||
maxHeight
|
||||
);
|
||||
heightRef.current = newHeight;
|
||||
if (panelRef.current) {
|
||||
panelRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
hasDraggedRef.current = true;
|
||||
const parent = containerRef.current?.parentElement;
|
||||
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
|
||||
const delta = startY - e.clientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(startHeight + delta, MIN_HEIGHT),
|
||||
maxHeight,
|
||||
);
|
||||
heightRef.current = newHeight;
|
||||
if (panelRef.current) {
|
||||
panelRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
setHeight(heightRef.current);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
setHeight(heightRef.current);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, [isExpanded]);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[isExpanded],
|
||||
);
|
||||
|
||||
// Compute expanded dimensions from parent
|
||||
const getExpandedDimensions = useCallback(() => {
|
||||
|
|
@ -111,7 +116,7 @@ export function ClaudeChatDrawer() {
|
|||
ref={containerRef}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-10 flex items-end justify-center transition-[padding] duration-300 ease-out",
|
||||
isExpanded ? "p-0" : "px-4 pb-6 pt-4"
|
||||
isExpanded ? "p-0" : "px-4 pt-4 pb-6",
|
||||
)}
|
||||
>
|
||||
{/* Floating toggle button */}
|
||||
|
|
@ -122,7 +127,7 @@ export function ClaudeChatDrawer() {
|
|||
"pointer-events-auto absolute right-4 bottom-6 flex size-12 items-center justify-center rounded-full border border-border bg-background shadow-lg transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl",
|
||||
isOpen
|
||||
? "pointer-events-none scale-50 opacity-0"
|
||||
: "scale-100 opacity-100"
|
||||
: "scale-100 opacity-100",
|
||||
)}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
|
|
@ -134,18 +139,20 @@ export function ClaudeChatDrawer() {
|
|||
ref={panelRef}
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-full flex-col overflow-hidden border bg-background transition-[height,max-width,border-radius,border-color,box-shadow,opacity,transform] duration-300 ease-out",
|
||||
isExpanded ? "border-transparent shadow-none" : "border-border shadow-2xl",
|
||||
isExpanded
|
||||
? "border-transparent shadow-none"
|
||||
: "border-border shadow-2xl",
|
||||
isOpen
|
||||
? "scale-100 opacity-100"
|
||||
: "pointer-events-none origin-bottom scale-95 opacity-0",
|
||||
isDragging && "!transition-none"
|
||||
isDragging && "!transition-none",
|
||||
)}
|
||||
style={panelStyle()}
|
||||
>
|
||||
{/* Header with drag handle, tab bar, and session selector */}
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<div className="flex items-center justify-start border-b border-border px-2 py-1">
|
||||
<div className="flex items-center justify-start border-border border-b px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
|
|
|
|||
|
|
@ -21,15 +21,43 @@ import { useDocumentStore } from "@/stores/document-store";
|
|||
// ─── Shell Detection ───
|
||||
|
||||
const SHELL_LANGUAGES = new Set([
|
||||
"bash", "sh", "shell", "zsh", "fish", "terminal", "console",
|
||||
"bash",
|
||||
"sh",
|
||||
"shell",
|
||||
"zsh",
|
||||
"fish",
|
||||
"terminal",
|
||||
"console",
|
||||
]);
|
||||
|
||||
function looksLikeShellCommand(code: string): boolean {
|
||||
const firstLine = code.trim().split("\n")[0].replace(/^[\$#]\s*/, "").trim();
|
||||
const firstLine = code
|
||||
.trim()
|
||||
.split("\n")[0]
|
||||
.replace(/^[$#]\s*/, "")
|
||||
.trim();
|
||||
const prefixes = [
|
||||
"wget", "curl", "tlmgr", "apt", "brew", "npm", "pip", "sudo",
|
||||
"mkdir", "cd ", "cp ", "mv ", "rm ", "git ", "make", "tar ", "unzip",
|
||||
"latexmk", "pdflatex", "xelatex", "bibtex",
|
||||
"wget",
|
||||
"curl",
|
||||
"tlmgr",
|
||||
"apt",
|
||||
"brew",
|
||||
"npm",
|
||||
"pip",
|
||||
"sudo",
|
||||
"mkdir",
|
||||
"cd ",
|
||||
"cp ",
|
||||
"mv ",
|
||||
"rm ",
|
||||
"git ",
|
||||
"make",
|
||||
"tar ",
|
||||
"unzip",
|
||||
"latexmk",
|
||||
"pdflatex",
|
||||
"xelatex",
|
||||
"bibtex",
|
||||
];
|
||||
return prefixes.some((p) => firstLine.startsWith(p));
|
||||
}
|
||||
|
|
@ -64,7 +92,8 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
|||
const match = /language-(\w+)/.exec(codeClassName || "");
|
||||
const language = match?.[1];
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
const isBlock = node?.position &&
|
||||
const isBlock =
|
||||
node?.position &&
|
||||
node.position.start.line !== node.position.end.line;
|
||||
|
||||
if (!match && !isBlock) {
|
||||
|
|
@ -138,9 +167,12 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
|
|||
stderr: result.stderr,
|
||||
});
|
||||
// Refresh file tree to pick up any new/deleted files
|
||||
useDocumentStore.getState().refreshFiles().catch((err) => {
|
||||
console.error("Failed to refresh files:", err);
|
||||
});
|
||||
useDocumentStore
|
||||
.getState()
|
||||
.refreshFiles()
|
||||
.catch((err) => {
|
||||
console.error("Failed to refresh files:", err);
|
||||
});
|
||||
} catch (err: any) {
|
||||
setRunState({ status: "error", message: err?.message || String(err) });
|
||||
}
|
||||
|
|
@ -186,7 +218,10 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
|
|||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
<AlertTriangleIcon className="size-3.5 text-yellow-500" />
|
||||
<span className="text-xs">
|
||||
Run in <code className="rounded bg-muted px-1 text-xs">{projectRoot?.split("/").pop()}/</code>
|
||||
Run in{" "}
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
{projectRoot?.split("/").pop()}/
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -213,7 +248,9 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
|
|||
{runState.status === "running" && (
|
||||
<div className="mt-1 flex items-center gap-2 rounded-lg border border-border bg-[#1e1e2e] px-3 py-2 text-sm">
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="font-mono text-muted-foreground text-xs">Running...</span>
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
Running...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -246,9 +283,9 @@ const CommandOutput: FC<{
|
|||
}> = ({ exitCode, stdout, stderr }) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const success = exitCode === 0;
|
||||
const output = (stdout + (stderr ? "\n" + stderr : "")).trim();
|
||||
const output = (stdout + (stderr ? `\n${stderr}` : "")).trim();
|
||||
const truncated =
|
||||
output.length > 2000 ? output.slice(0, 2000) + "\n..." : output;
|
||||
output.length > 2000 ? `${output.slice(0, 2000)}\n...` : output;
|
||||
|
||||
return (
|
||||
<div className="mt-1 rounded-lg border border-border bg-[#1e1e2e] text-sm">
|
||||
|
|
@ -276,8 +313,8 @@ const CommandOutput: FC<{
|
|||
</span>
|
||||
</button>
|
||||
{expanded && truncated && (
|
||||
<div className="max-h-40 overflow-auto border-t border-border/50 px-3 py-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-300">
|
||||
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-gray-300 text-xs">
|
||||
{truncated}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
|
|||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">Proposed Changes</span>
|
||||
{totalChanges > 1 && (
|
||||
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 font-medium text-violet-600 text-xs dark:text-violet-400">
|
||||
{changeIndex + 1}/{totalChanges} files
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -39,7 +39,7 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
|
|||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={onKeep}
|
||||
className="flex items-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs hover:bg-green-600/30 transition-colors"
|
||||
className="flex items-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs transition-colors hover:bg-green-600/30"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
Keep All
|
||||
|
|
@ -49,7 +49,7 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
|
|||
</button>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
className="flex items-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs hover:bg-red-600/30 transition-colors"
|
||||
className="flex items-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs transition-colors hover:bg-red-600/30"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Undo All
|
||||
|
|
|
|||
|
|
@ -46,12 +46,11 @@ export function SessionSelector() {
|
|||
const loadSessions = useCallback(async () => {
|
||||
if (!projectRoot) return;
|
||||
setIsLoading(true);
|
||||
log.debug("loading sessions for projectRoot: " + projectRoot);
|
||||
log.debug(`loading sessions for projectRoot: ${projectRoot}`);
|
||||
try {
|
||||
const result = await invoke<ClaudeSessionInfo[]>(
|
||||
"list_claude_sessions",
|
||||
{ projectPath: projectRoot },
|
||||
);
|
||||
const result = await invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
|
||||
projectPath: projectRoot,
|
||||
});
|
||||
log.debug("loaded sessions", { count: result.length });
|
||||
setSessions(result);
|
||||
} catch (err) {
|
||||
|
|
@ -75,7 +74,7 @@ export function SessionSelector() {
|
|||
(sid: string) => {
|
||||
if (isStreaming) return;
|
||||
if (sid === sessionId) return;
|
||||
log.debug("selecting session: " + sid);
|
||||
log.debug(`selecting session: ${sid}`);
|
||||
resumeSession(sid);
|
||||
},
|
||||
[isStreaming, sessionId, resumeSession],
|
||||
|
|
@ -119,7 +118,7 @@ export function SessionSelector() {
|
|||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
<div className="px-2 py-4 text-center text-muted-foreground text-sm">
|
||||
No previous sessions
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -132,7 +131,7 @@ export function SessionSelector() {
|
|||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{session.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(session.last_modified)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,27 @@
|
|||
import { type FC, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon, XIcon, SearchIcon, FlaskConicalIcon, ChevronRightIcon, ChevronLeftIcon } from "lucide-react";
|
||||
import {
|
||||
CommandIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
TerminalIcon,
|
||||
FileCodeIcon,
|
||||
ZapIcon,
|
||||
XIcon,
|
||||
SearchIcon,
|
||||
FlaskConicalIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronLeftIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SlashCommand {
|
||||
|
|
@ -43,12 +63,22 @@ function scopeToTab(scope: string): Tab {
|
|||
}
|
||||
|
||||
function getCommandIcon(command: SlashCommand) {
|
||||
if (command.scope === "skill") return <FlaskConicalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.has_bash_commands) return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.has_file_references) return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "project") return <FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "user") return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "default") return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "skill")
|
||||
return (
|
||||
<FlaskConicalIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
);
|
||||
if (command.has_bash_commands)
|
||||
return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.has_file_references)
|
||||
return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "project")
|
||||
return (
|
||||
<FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
);
|
||||
if (command.scope === "user")
|
||||
return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "default")
|
||||
return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
return <ZapIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
|
|
@ -64,9 +94,13 @@ const SCORE_MATCH_CAPITAL = 0.7;
|
|||
const SCORE_MATCH_DOT = 0.6;
|
||||
const SCORE_MAX_LEADING_GAP = -0.05;
|
||||
|
||||
function isWordBoundary(prev: string, curr: string): boolean {
|
||||
function _isWordBoundary(prev: string, curr: string): boolean {
|
||||
return (
|
||||
prev === "-" || prev === "_" || prev === " " || prev === "/" || prev === "." ||
|
||||
prev === "-" ||
|
||||
prev === "_" ||
|
||||
prev === " " ||
|
||||
prev === "/" ||
|
||||
prev === "." ||
|
||||
(prev === prev.toLowerCase() && curr === curr.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
|
@ -75,7 +109,8 @@ function bonusFor(prev: string, curr: string): number {
|
|||
if (prev === "/") return SCORE_MATCH_SLASH;
|
||||
if (prev === "-" || prev === "_" || prev === " ") return SCORE_MATCH_WORD;
|
||||
if (prev === ".") return SCORE_MATCH_DOT;
|
||||
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
|
||||
if (prev === prev.toLowerCase() && curr === curr.toUpperCase())
|
||||
return SCORE_MATCH_CAPITAL;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -117,14 +152,17 @@ function fuzzyScore(query: string, candidate: string): number {
|
|||
|
||||
if (i === 0) {
|
||||
// First char of query
|
||||
score = j === 0
|
||||
? SCORE_MATCH_CONSECUTIVE // start of string
|
||||
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) + bonusFor(candidate[j - 1], candidate[j]);
|
||||
score =
|
||||
j === 0
|
||||
? SCORE_MATCH_CONSECUTIVE // start of string
|
||||
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) +
|
||||
bonusFor(candidate[j - 1], candidate[j]);
|
||||
} else if (j > 0) {
|
||||
// Consecutive match bonus
|
||||
const consecutive = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
|
||||
// Non-consecutive: gap penalty from best previous match
|
||||
const boundary = M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
|
||||
const boundary =
|
||||
M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
|
||||
score = Math.max(consecutive, boundary);
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +245,7 @@ function scoreCommand(cmd: SlashCommand, q: string): number {
|
|||
|
||||
// 2. Description: only substring (contains) match to avoid false positives on long text
|
||||
let descScore = -Infinity;
|
||||
if (cmd.description && cmd.description.toLowerCase().includes(q.toLowerCase())) {
|
||||
if (cmd.description?.toLowerCase().includes(q.toLowerCase())) {
|
||||
descScore = q.length * 0.3; // modest score for description-only match
|
||||
}
|
||||
|
||||
|
|
@ -215,10 +253,7 @@ function scoreCommand(cmd: SlashCommand, q: string): number {
|
|||
if (fuzzy > -Infinity) return fuzzy;
|
||||
|
||||
// 3. Typo-tolerant fallback on command key / name (handles bioarxiv → biorxiv)
|
||||
const typo = Math.max(
|
||||
typoScore(q, cmdKey),
|
||||
typoScore(q, cmd.name),
|
||||
);
|
||||
const typo = Math.max(typoScore(q, cmdKey), typoScore(q, cmd.name));
|
||||
|
||||
return typo;
|
||||
}
|
||||
|
|
@ -252,23 +287,58 @@ function SkillPreview({ content }: { content: string }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm prose-invert max-w-none text-xs leading-relaxed text-muted-foreground">
|
||||
<div className="prose prose-sm prose-invert max-w-none text-muted-foreground text-xs leading-relaxed">
|
||||
{body.split("\n").map((line, i) => {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed.startsWith("# ")) {
|
||||
return <h3 key={i} className="text-sm font-semibold text-foreground mt-3 mb-1">{trimmed.slice(2)}</h3>;
|
||||
return (
|
||||
<h3
|
||||
key={i}
|
||||
className="mt-3 mb-1 font-semibold text-foreground text-sm"
|
||||
>
|
||||
{trimmed.slice(2)}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
if (trimmed.startsWith("## ")) {
|
||||
return <h4 key={i} className="text-xs font-semibold text-foreground mt-2.5 mb-0.5">{trimmed.slice(3)}</h4>;
|
||||
return (
|
||||
<h4
|
||||
key={i}
|
||||
className="mt-2.5 mb-0.5 font-semibold text-foreground text-xs"
|
||||
>
|
||||
{trimmed.slice(3)}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
if (trimmed.startsWith("### ")) {
|
||||
return <h5 key={i} className="text-xs font-medium text-foreground mt-2 mb-0.5">{trimmed.slice(4)}</h5>;
|
||||
return (
|
||||
<h5
|
||||
key={i}
|
||||
className="mt-2 mb-0.5 font-medium text-foreground text-xs"
|
||||
>
|
||||
{trimmed.slice(4)}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
||||
return <div key={i} className="pl-3 before:content-['·'] before:mr-1.5 before:text-muted-foreground/50">{trimmed.slice(2)}</div>;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="pl-3 before:mr-1.5 before:text-muted-foreground/50 before:content-['·']"
|
||||
>
|
||||
{trimmed.slice(2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (trimmed.startsWith("```")) {
|
||||
return <div key={i} className="font-mono text-[10px] text-muted-foreground/70">{trimmed}</div>;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="font-mono text-[10px] text-muted-foreground/70"
|
||||
>
|
||||
{trimmed}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (trimmed === "") {
|
||||
return <div key={i} className="h-1.5" />;
|
||||
|
|
@ -292,7 +362,11 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
const [activeTab, setActiveTab] = useState<Tab>("skills");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; right: number; bottom: number }>({ left: 0, right: 0, bottom: 0 });
|
||||
const [pos, setPos] = useState<{
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}>({ left: 0, right: 0, bottom: 0 });
|
||||
|
||||
const isSearching = query.length > 0;
|
||||
|
||||
|
|
@ -352,9 +426,16 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
const searchGroups = useMemo(() => {
|
||||
if (!isSearching) return null;
|
||||
|
||||
const groups: { label: string; items: { cmd: SlashCommand; globalIndex: number }[] }[] = [];
|
||||
const groups: {
|
||||
label: string;
|
||||
items: { cmd: SlashCommand; globalIndex: number }[];
|
||||
}[] = [];
|
||||
const order: Tab[] = ["skills", "default", "custom"];
|
||||
const labels: Record<Tab, string> = { skills: "Skills", default: "Default", custom: "Custom" };
|
||||
const labels: Record<Tab, string> = {
|
||||
skills: "Skills",
|
||||
default: "Default",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
for (const tab of order) {
|
||||
const items: { cmd: SlashCommand; globalIndex: number }[] = [];
|
||||
|
|
@ -430,7 +511,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
const selected = listRef.current.querySelector(`[data-index="${selectedIndex}"]`);
|
||||
const selected = listRef.current.querySelector(
|
||||
`[data-index="${selectedIndex}"]`,
|
||||
);
|
||||
selected?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
|
@ -443,7 +526,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
key={cmd.id}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-colors",
|
||||
"flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-left transition-colors",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
|
|
@ -453,9 +536,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
{getCommandIcon(cmd)}
|
||||
<span className="font-mono text-sm truncate">{cmd.full_command}</span>
|
||||
<span className="truncate font-mono text-sm">{cmd.full_command}</span>
|
||||
{cmd.description && (
|
||||
<span className="truncate text-xs text-muted-foreground flex-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
|
||||
{cmd.description}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -482,29 +565,34 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
if (isSearching) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<SearchIcon className="size-6 text-muted-foreground mb-2" />
|
||||
<span className="text-sm text-muted-foreground">No results for "{query}"</span>
|
||||
<SearchIcon className="mb-2 size-6 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
No results for "{query}"
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hints: Record<Tab, React.ReactNode> = {
|
||||
skills: (
|
||||
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
|
||||
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
|
||||
Install scientific skills from the sidebar menu.
|
||||
</p>
|
||||
),
|
||||
default: null,
|
||||
custom: (
|
||||
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
|
||||
Add commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
|
||||
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
|
||||
Add commands in <code className="px-1">.claude/commands/</code> or{" "}
|
||||
<code className="px-1">~/.claude/commands/</code>
|
||||
</p>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<span className="text-sm text-muted-foreground">No commands available</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
No commands available
|
||||
</span>
|
||||
{hints[activeTab]}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -514,7 +602,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
<div className="flex-1 overflow-y-auto" ref={listRef}>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
<span className="text-muted-foreground text-sm">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -526,7 +614,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
<div className="space-y-2">
|
||||
{searchGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h3 className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<h3 className="px-3 py-1 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{group.label}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
|
|
@ -559,16 +647,18 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
}}
|
||||
>
|
||||
{/* Left side: list */}
|
||||
<div className={cn(
|
||||
"flex flex-col overflow-hidden transition-all",
|
||||
showPreview ? "w-[45%] min-w-[200px]" : "w-full",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden transition-all",
|
||||
showPreview ? "w-[45%] min-w-[200px]" : "w-full",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-3 pt-2.5 pb-2 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="shrink-0 border-border border-b px-3 pt-2.5 pb-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
<span className="font-medium text-sm">
|
||||
{isSearching ? `Search: "${query}"` : "Commands"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -590,7 +680,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 font-medium text-xs transition-colors",
|
||||
activeTab === tab
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
|
|
@ -600,14 +690,20 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
setActiveTab(tab);
|
||||
}}
|
||||
>
|
||||
{tab === "skills" ? "Skills" : tab === "default" ? "Default" : "Custom"}
|
||||
{tab === "skills"
|
||||
? "Skills"
|
||||
: tab === "default"
|
||||
? "Default"
|
||||
: "Custom"}
|
||||
{tabCounts[tab] > 0 && (
|
||||
<span className={cn(
|
||||
"rounded-full px-1.5 text-[10px] leading-4 tabular-nums",
|
||||
activeTab === tab
|
||||
? "bg-primary-foreground/20 text-primary-foreground"
|
||||
: "bg-muted-foreground/15 text-muted-foreground",
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 text-[10px] tabular-nums leading-4",
|
||||
activeTab === tab
|
||||
? "bg-primary-foreground/20 text-primary-foreground"
|
||||
: "bg-muted-foreground/15 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tabCounts[tab]}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -620,18 +716,19 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
{renderList()}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-3 py-1 shrink-0">
|
||||
<div className="shrink-0 border-border border-t px-3 py-1">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
↑↓ Navigate · Enter Select · {canPreview ? "→ Preview · " : ""}Esc Close
|
||||
↑↓ Navigate · Enter Select · {canPreview ? "→ Preview · " : ""}Esc
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side: preview panel */}
|
||||
{showPreview && selectedCommand && (
|
||||
<div className="flex flex-col w-[55%] border-l border-border">
|
||||
<div className="flex w-[55%] flex-col border-border border-l">
|
||||
{/* Preview header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2 border-border border-b px-3 py-2">
|
||||
<button
|
||||
aria-label="Close preview"
|
||||
onMouseDown={(e) => {
|
||||
|
|
@ -643,7 +740,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
|||
<ChevronLeftIcon className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<FlaskConicalIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="font-mono text-sm font-medium truncate">{selectedCommand.full_command}</span>
|
||||
<span className="truncate font-medium font-mono text-sm">
|
||||
{selectedCommand.full_command}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Preview body */}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
FileEditIcon,
|
||||
|
|
@ -17,7 +16,10 @@ import {
|
|||
TerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useClaudeChatStore, type ContentBlock } from "@/stores/claude-chat-store";
|
||||
import {
|
||||
useClaudeChatStore,
|
||||
type ContentBlock,
|
||||
} from "@/stores/claude-chat-store";
|
||||
|
||||
interface ToolWidgetProps {
|
||||
toolUse: ContentBlock;
|
||||
|
|
@ -27,16 +29,30 @@ interface ToolWidgetProps {
|
|||
export const ToolWidget: FC<ToolWidgetProps> = ({ toolUse, toolResult }) => {
|
||||
const name = toolUse.name?.toLowerCase() || "";
|
||||
|
||||
if (name === "write") return <WriteWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "edit" || name === "multiedit") return <EditWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "read") return <ReadWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "bash") return <BashWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "glob") return <GlobWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "grep") return <GrepWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "askuserquestion") return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "todowrite") return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "write")
|
||||
return <WriteWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "edit" || name === "multiedit")
|
||||
return <EditWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "read")
|
||||
return <ReadWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "bash")
|
||||
return <BashWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "glob")
|
||||
return <GlobWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "grep")
|
||||
return <GrepWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "askuserquestion")
|
||||
return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "todowrite")
|
||||
return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
|
||||
|
||||
return <GenericWidget name={toolUse.name || "unknown"} input={toolUse.input} result={toolResult} />;
|
||||
return (
|
||||
<GenericWidget
|
||||
name={toolUse.name || "unknown"}
|
||||
input={toolUse.input}
|
||||
result={toolResult}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Status Icon ───
|
||||
|
|
@ -48,24 +64,31 @@ const StatusIcon: FC<{ result?: ContentBlock }> = ({ result }) => {
|
|||
// Tool was cancelled (stop pressed) — show stopped state
|
||||
return <CircleIcon className="size-3.5 text-muted-foreground" />;
|
||||
}
|
||||
return <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />;
|
||||
return (
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||
);
|
||||
}
|
||||
if (result.is_error) {
|
||||
return <span className="text-sm text-destructive">!</span>;
|
||||
return <span className="text-destructive text-sm">!</span>;
|
||||
}
|
||||
return <CheckIcon className="size-3.5 text-green-600" />;
|
||||
};
|
||||
|
||||
// ─── Write Widget ───
|
||||
|
||||
const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
||||
const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
return (
|
||||
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||
<StatusIcon result={result} />
|
||||
<FileOutputIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{result ? "Wrote" : "Writing"}{" "}
|
||||
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
{input?.file_path}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -73,7 +96,10 @@ const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result
|
|||
|
||||
// ─── Edit Widget ───
|
||||
|
||||
const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
||||
const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -87,18 +113,25 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
|||
<FileEditIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{result ? "Edited" : "Editing"}{" "}
|
||||
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
{input?.file_path}
|
||||
</code>
|
||||
</span>
|
||||
{(input?.old_string || input?.edits) && (
|
||||
expanded
|
||||
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{(input?.old_string || input?.edits) &&
|
||||
(expanded ? (
|
||||
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
))}
|
||||
</button>
|
||||
{expanded && input?.old_string && (
|
||||
<div className="border-t border-border px-3 py-2 font-mono text-xs">
|
||||
<div className="mb-1 text-red-500">- {truncate(input.old_string, 200)}</div>
|
||||
<div className="text-green-500">+ {truncate(input.new_string, 200)}</div>
|
||||
<div className="border-border border-t px-3 py-2 font-mono text-xs">
|
||||
<div className="mb-1 text-red-500">
|
||||
- {truncate(input.old_string, 200)}
|
||||
</div>
|
||||
<div className="text-green-500">
|
||||
+ {truncate(input.new_string, 200)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -107,14 +140,19 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
|||
|
||||
// ─── Read Widget ───
|
||||
|
||||
const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
||||
const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
return (
|
||||
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||
<StatusIcon result={result} />
|
||||
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{result ? "Read" : "Reading"}{" "}
|
||||
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
{input?.file_path}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -122,10 +160,14 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
|||
|
||||
// ─── Bash Widget ───
|
||||
|
||||
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
||||
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const command = input?.command || input?.description || "";
|
||||
const resultContent = typeof result?.content === "string" ? result.content : "";
|
||||
const resultContent =
|
||||
typeof result?.content === "string" ? result.content : "";
|
||||
|
||||
return (
|
||||
<div className="my-1.5 rounded-lg border border-border bg-[#1e1e2e] text-sm">
|
||||
|
|
@ -136,16 +178,19 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
|||
>
|
||||
<StatusIcon result={result} />
|
||||
<TerminalIcon className="size-3.5 shrink-0 text-green-400" />
|
||||
<code className="min-w-0 truncate text-xs text-green-300">$ {truncate(command, 80)}</code>
|
||||
{result && (
|
||||
expanded
|
||||
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<code className="min-w-0 truncate text-green-300 text-xs">
|
||||
$ {truncate(command, 80)}
|
||||
</code>
|
||||
{result &&
|
||||
(expanded ? (
|
||||
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
))}
|
||||
</button>
|
||||
{expanded && resultContent && (
|
||||
<div className="max-h-40 overflow-auto border-t border-border/50 px-3 py-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-300">
|
||||
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-gray-300 text-xs">
|
||||
{truncate(resultContent, 2000)}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -156,7 +201,10 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
|||
|
||||
// ─── Glob Widget ───
|
||||
|
||||
const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
||||
const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
return (
|
||||
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||
<StatusIcon result={result} />
|
||||
|
|
@ -171,7 +219,10 @@ const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
|||
|
||||
// ─── Grep Widget ───
|
||||
|
||||
const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
||||
const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
return (
|
||||
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||
<StatusIcon result={result} />
|
||||
|
|
@ -186,7 +237,10 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
|
|||
|
||||
// ─── AskUserQuestion Widget ───
|
||||
|
||||
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
||||
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
const questions: any[] = input?.questions || [];
|
||||
const [answered, setAnswered] = useState(false);
|
||||
|
||||
|
|
@ -194,9 +248,10 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
|||
// The process is killed when AskUserQuestion is detected, so result may be undefined.
|
||||
// Options are clickable when there's no result or an error result.
|
||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||
const needsUserAnswer = !answered && !isStreaming && (!result || result.is_error);
|
||||
const needsUserAnswer =
|
||||
!answered && !isStreaming && (!result || result.is_error);
|
||||
|
||||
const handleOptionClick = (question: string, label: string) => {
|
||||
const handleOptionClick = (_question: string, label: string) => {
|
||||
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
|
||||
if (isStreaming) return;
|
||||
setAnswered(true);
|
||||
|
|
@ -225,11 +280,13 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
|||
: "Question answered";
|
||||
|
||||
return (
|
||||
<div className={`my-1.5 rounded-lg border text-sm ${
|
||||
needsUserAnswer
|
||||
? "border-blue-500/40 bg-blue-500/10"
|
||||
: "border-blue-500/20 bg-blue-500/5"
|
||||
}`}>
|
||||
<div
|
||||
className={`my-1.5 rounded-lg border text-sm ${
|
||||
needsUserAnswer
|
||||
? "border-blue-500/40 bg-blue-500/10"
|
||||
: "border-blue-500/20 bg-blue-500/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
{needsUserAnswer ? (
|
||||
<MessageCircleQuestionIcon className="size-3.5 text-blue-500" />
|
||||
|
|
@ -243,22 +300,24 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
|||
{headerLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 border-t border-blue-500/20 px-3 py-2.5">
|
||||
<div className="space-y-3 border-blue-500/20 border-t px-3 py-2.5">
|
||||
{questions.map((q: any, qIdx: number) => (
|
||||
<div key={qIdx} className="space-y-1.5">
|
||||
{q.header && (
|
||||
<span className="inline-block rounded-full bg-blue-500/15 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="inline-block rounded-full bg-blue-500/15 px-2 py-0.5 font-medium text-blue-600 text-xs dark:text-blue-400">
|
||||
{q.header}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground">{q.question}</p>
|
||||
<p className="font-medium text-foreground text-sm">{q.question}</p>
|
||||
<div className="space-y-1 pl-1">
|
||||
{q.options?.map((opt: any, oIdx: number) => (
|
||||
<button
|
||||
type="button"
|
||||
key={oIdx}
|
||||
disabled={!needsUserAnswer}
|
||||
onClick={() => needsUserAnswer && handleOptionClick(q.question, opt.label)}
|
||||
onClick={() =>
|
||||
needsUserAnswer && handleOptionClick(q.question, opt.label)
|
||||
}
|
||||
className={`flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
|
||||
needsUserAnswer
|
||||
? "cursor-pointer hover:bg-blue-500/15"
|
||||
|
|
@ -266,18 +325,26 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
|||
}`}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
<CircleIcon className={`size-3.5 ${
|
||||
needsUserAnswer ? "text-blue-500/50" : "text-muted-foreground/40"
|
||||
}`} />
|
||||
<CircleIcon
|
||||
className={`size-3.5 ${
|
||||
needsUserAnswer
|
||||
? "text-blue-500/50"
|
||||
: "text-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`text-sm ${
|
||||
needsUserAnswer ? "text-foreground" : "text-muted-foreground"
|
||||
}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
needsUserAnswer
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{opt.description && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5">
|
||||
<p className="mt-0.5 text-muted-foreground/70 text-xs">
|
||||
{opt.description}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -294,7 +361,10 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
|
|||
|
||||
// ─── TodoWrite Widget ───
|
||||
|
||||
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
|
||||
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const todos: any[] = Array.isArray(input?.todos) ? input.todos : [];
|
||||
|
||||
|
|
@ -323,12 +393,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
|
|||
<span className="text-muted-foreground">
|
||||
Todos ({completedCount}/{todos.length})
|
||||
</span>
|
||||
{expanded
|
||||
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />}
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && todos.length > 0 && (
|
||||
<div className="space-y-0.5 border-t border-border px-3 py-2">
|
||||
<div className="space-y-0.5 border-border border-t px-3 py-2">
|
||||
{todos.map((todo, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
|
|
@ -340,14 +412,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
|
|||
<span
|
||||
className={`text-xs ${
|
||||
todo.status === "completed"
|
||||
? "line-through text-muted-foreground"
|
||||
? "text-muted-foreground line-through"
|
||||
: todo.status === "in_progress"
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{todo.status === "in_progress"
|
||||
? (todo.activeForm || todo.content)
|
||||
? todo.activeForm || todo.content
|
||||
: todo.content}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -360,11 +432,11 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
|
|||
|
||||
// ─── Generic Widget ───
|
||||
|
||||
const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = ({
|
||||
name,
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
const GenericWidget: FC<{
|
||||
name: string;
|
||||
input: any;
|
||||
result?: ContentBlock;
|
||||
}> = ({ name, input, result }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -379,13 +451,15 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
|
|||
<span className="text-muted-foreground">
|
||||
{result ? "Ran" : "Running"} <code className="text-xs">{name}</code>
|
||||
</span>
|
||||
{expanded
|
||||
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />}
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && input && (
|
||||
<div className="max-h-32 overflow-auto border-t border-border px-3 py-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
|
||||
<div className="max-h-32 overflow-auto border-border border-t px-3 py-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-muted-foreground text-xs">
|
||||
{JSON.stringify(input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -396,29 +470,35 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
|
|||
|
||||
// ─── Thinking Widget ───
|
||||
|
||||
export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ thinking }) => {
|
||||
export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({
|
||||
thinking,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const trimmed = thinking.trim();
|
||||
|
||||
return (
|
||||
<div className="my-1.5 rounded-lg border border-muted-foreground/20 bg-muted-foreground/5 overflow-hidden">
|
||||
<div className="my-1.5 overflow-hidden rounded-lg border border-muted-foreground/20 bg-muted-foreground/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 hover:bg-muted-foreground/10 transition-colors"
|
||||
className="flex w-full items-center justify-between px-3 py-2 transition-colors hover:bg-muted-foreground/10"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<BotIcon className="size-4 text-muted-foreground" />
|
||||
<SparklesIcon className="size-2.5 text-muted-foreground/70 absolute -top-1 -right-1 animate-pulse" />
|
||||
<SparklesIcon className="absolute -top-1 -right-1 size-2.5 animate-pulse text-muted-foreground/70" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground italic">Thinking...</span>
|
||||
<span className="font-medium text-muted-foreground text-sm italic">
|
||||
Thinking...
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRightIcon className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`} />
|
||||
<ChevronRightIcon
|
||||
className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-muted-foreground/20 px-3 pb-3 pt-2">
|
||||
<pre className="whitespace-pre-wrap rounded-lg bg-muted-foreground/5 p-3 font-mono text-xs text-muted-foreground italic">
|
||||
<div className="border-muted-foreground/20 border-t px-3 pt-2 pb-3">
|
||||
<pre className="whitespace-pre-wrap rounded-lg bg-muted-foreground/5 p-3 font-mono text-muted-foreground text-xs italic">
|
||||
{trimmed}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -431,5 +511,5 @@ export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ t
|
|||
|
||||
function truncate(str: string, max: number): string {
|
||||
if (!str) return "";
|
||||
return str.length > max ? str.slice(0, max) + "..." : str;
|
||||
return str.length > max ? `${str.slice(0, max)}...` : str;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
DownloadIcon,
|
||||
|
|
@ -16,7 +16,10 @@ import {
|
|||
} from "lucide-react";
|
||||
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useClaudeSetupStore, type StepInfo } from "@/stores/claude-setup-store";
|
||||
import {
|
||||
useClaudeSetupStore,
|
||||
type StepInfo,
|
||||
} from "@/stores/claude-setup-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Event Hooks ───
|
||||
|
|
@ -34,7 +37,9 @@ function useInstallEvents() {
|
|||
const timer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
const store = useClaudeSetupStore.getState();
|
||||
const downloadStep = store.installSteps.find((s) => s.id === "downloading");
|
||||
const downloadStep = store.installSteps.find(
|
||||
(s) => s.id === "downloading",
|
||||
);
|
||||
if (downloadStep?.status === "active") {
|
||||
store._advanceInstallStep("installing");
|
||||
}
|
||||
|
|
@ -52,7 +57,11 @@ function useInstallEvents() {
|
|||
if (lower.includes("setting up") || lower.includes("installing")) {
|
||||
store._advanceInstallStep("installing");
|
||||
}
|
||||
if (lower.includes("complete") || lower.includes("successfully") || line.includes("✅")) {
|
||||
if (
|
||||
lower.includes("complete") ||
|
||||
lower.includes("successfully") ||
|
||||
line.includes("✅")
|
||||
) {
|
||||
store._advanceInstallStep("verifying");
|
||||
}
|
||||
});
|
||||
|
|
@ -62,11 +71,14 @@ function useInstallEvents() {
|
|||
useClaudeSetupStore.getState()._appendInstallLog(event.payload);
|
||||
});
|
||||
|
||||
const unlistenComplete = await listen<boolean>("install-complete", (event) => {
|
||||
if (cancelled) return;
|
||||
clearTimeout(timer);
|
||||
useClaudeSetupStore.getState()._finishInstall(event.payload);
|
||||
});
|
||||
const unlistenComplete = await listen<boolean>(
|
||||
"install-complete",
|
||||
(event) => {
|
||||
if (cancelled) return;
|
||||
clearTimeout(timer);
|
||||
useClaudeSetupStore.getState()._finishInstall(event.payload);
|
||||
},
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
unlistenOutput();
|
||||
|
|
@ -102,7 +114,7 @@ function useLoginEvents() {
|
|||
}, 1500);
|
||||
|
||||
(async () => {
|
||||
const unlistenOutput = await listen<string>("login-output", (event) => {
|
||||
const unlistenOutput = await listen<string>("login-output", (_event) => {
|
||||
if (cancelled) return;
|
||||
// Any output means browser is open, advance to waiting
|
||||
useClaudeSetupStore.getState()._advanceLoginStep("waiting-auth");
|
||||
|
|
@ -112,11 +124,14 @@ function useLoginEvents() {
|
|||
// ignore stderr for login
|
||||
});
|
||||
|
||||
const unlistenComplete = await listen<boolean>("login-complete", (event) => {
|
||||
if (cancelled) return;
|
||||
clearTimeout(timer);
|
||||
useClaudeSetupStore.getState()._finishLogin(event.payload);
|
||||
});
|
||||
const unlistenComplete = await listen<boolean>(
|
||||
"login-complete",
|
||||
(event) => {
|
||||
if (cancelled) return;
|
||||
clearTimeout(timer);
|
||||
useClaudeSetupStore.getState()._finishLogin(event.payload);
|
||||
},
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
unlistenOutput();
|
||||
|
|
@ -159,7 +174,7 @@ function StepRow({ step }: { step: StepInfo }) {
|
|||
step.status === "complete" && "text-green-600",
|
||||
step.status === "active" && "font-medium text-foreground",
|
||||
step.status === "pending" && "text-muted-foreground/60",
|
||||
step.status === "error" && "text-destructive"
|
||||
step.status === "error" && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
|
|
@ -184,12 +199,12 @@ function InstallLogOutput() {
|
|||
<div className="mt-1">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-3 transition-transform duration-200",
|
||||
visible && "rotate-90"
|
||||
visible && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{visible ? "Hide logs" : "Show logs"}
|
||||
|
|
@ -200,12 +215,12 @@ function InstallLogOutput() {
|
|||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[max-height] duration-300 ease-in-out",
|
||||
visible ? "max-h-40" : "max-h-0"
|
||||
visible ? "max-h-40" : "max-h-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="mt-2 max-h-36 overflow-y-auto rounded-md border border-border bg-foreground/3 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground"
|
||||
className="mt-2 max-h-36 overflow-y-auto rounded-md border border-border bg-foreground/3 p-3 font-mono text-[11px] text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<span className="italic">Waiting for output...</span>
|
||||
|
|
@ -244,7 +259,7 @@ export function ClaudeSetup() {
|
|||
return (
|
||||
<div className="flex w-full items-center justify-center gap-2 rounded-xl border border-border bg-muted/30 px-5 py-4">
|
||||
<LoaderIcon className="size-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Checking Claude Code...
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -256,8 +271,8 @@ export function ClaudeSetup() {
|
|||
<div className="flex w-full items-center gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
|
||||
<CheckCircle2Icon className="size-5 shrink-0 text-green-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Claude Code Ready</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
<p className="font-medium text-sm">Claude Code Ready</p>
|
||||
<p className="truncate text-muted-foreground text-xs">
|
||||
{[version, accountEmail].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -271,7 +286,7 @@ export function ClaudeSetup() {
|
|||
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Installing Claude Code</p>
|
||||
<p className="font-medium text-sm">Installing Claude Code</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0 pl-1">
|
||||
|
|
@ -291,7 +306,7 @@ export function ClaudeSetup() {
|
|||
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogInIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Signing in to Claude</p>
|
||||
<p className="font-medium text-sm">Signing in to Claude</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0 pl-1">
|
||||
|
|
@ -314,7 +329,7 @@ export function ClaudeSetup() {
|
|||
<div className="flex w-full flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircleIcon className="size-5 shrink-0 text-destructive" />
|
||||
<p className="text-sm font-medium">
|
||||
<p className="font-medium text-sm">
|
||||
{hasInstallSteps ? "Installation Failed" : "Setup Error"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -328,7 +343,7 @@ export function ClaudeSetup() {
|
|||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -354,10 +369,10 @@ export function ClaudeSetup() {
|
|||
<div className="flex items-center gap-2">
|
||||
<GitBranchIcon className="size-5 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Git for Windows Required</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Claude Code needs Git for Windows (git-bash) to work.
|
||||
Please install it first, then click "I've installed Git".
|
||||
<p className="font-medium text-sm">Git for Windows Required</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Claude Code needs Git for Windows (git-bash) to work. Please
|
||||
install it first, then click "I've installed Git".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -391,17 +406,13 @@ export function ClaudeSetup() {
|
|||
<div className="flex items-center gap-2">
|
||||
<TerminalIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Claude Code Required</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-sm">Claude Code Required</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
ClaudePrism needs Claude Code CLI to power AI features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={install}
|
||||
>
|
||||
<Button size="sm" className="w-full gap-2" onClick={install}>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
Install Claude Code
|
||||
</Button>
|
||||
|
|
@ -418,22 +429,18 @@ export function ClaudeSetup() {
|
|||
<div className="flex items-center gap-2">
|
||||
<LogInIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Sign in to Claude</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-sm">Sign in to Claude</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Authenticate with your Anthropic account to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{version && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Claude Code {version} installed
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={login}
|
||||
>
|
||||
<Button size="sm" className="w-full gap-2" onClick={login}>
|
||||
<LogInIcon className="size-3.5" />
|
||||
Sign in with Browser
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ export function DebugPage() {
|
|||
|
||||
// Fetch system info once
|
||||
useEffect(() => {
|
||||
invoke<SystemInfo>("get_system_info").then(setSystemInfo).catch(() => {});
|
||||
invoke<SystemInfo>("get_system_info")
|
||||
.then(setSystemInfo)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Auto-scroll logs only if already scrolled to bottom.
|
||||
|
|
@ -55,7 +57,8 @@ export function DebugPage() {
|
|||
const container = logContainerRef.current;
|
||||
if (!container) return;
|
||||
// Check before new content is painted
|
||||
const gap = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const gap =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
wasAtBottomRef.current = gap < 40;
|
||||
}); // runs every render, before paint
|
||||
|
||||
|
|
@ -87,7 +90,11 @@ export function DebugPage() {
|
|||
|
||||
const formatTime = (ts: number) => {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString("en-US", { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0");
|
||||
return (
|
||||
d.toLocaleTimeString("en-US", { hour12: false }) +
|
||||
"." +
|
||||
String(d.getMilliseconds()).padStart(3, "0")
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -96,14 +103,18 @@ export function DebugPage() {
|
|||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BugIcon className="size-4" />
|
||||
<h1 className="text-sm font-semibold">Debug</h1>
|
||||
<h1 className="font-semibold text-sm">Debug</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyReport}
|
||||
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 font-medium text-primary-foreground text-xs hover:bg-primary/90"
|
||||
>
|
||||
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy Bug Report"}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -115,7 +126,7 @@ export function DebugPage() {
|
|||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-1.5 text-xs font-medium capitalize border-b-2 transition-colors ${
|
||||
className={`border-b-2 px-3 py-1.5 font-medium text-xs capitalize transition-colors ${
|
||||
tab === t
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
|
|
@ -133,7 +144,9 @@ export function DebugPage() {
|
|||
<div className="flex gap-2">
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value as LogLevel | "all")}
|
||||
onChange={(e) =>
|
||||
setLevelFilter(e.target.value as LogLevel | "all")
|
||||
}
|
||||
className="rounded border bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="all">All levels</option>
|
||||
|
|
@ -149,7 +162,9 @@ export function DebugPage() {
|
|||
>
|
||||
<option value="">All sources</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
|
|
@ -169,18 +184,31 @@ export function DebugPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={logContainerRef} className="flex-1 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]">
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]"
|
||||
>
|
||||
{filteredEntries.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-4">No log entries</p>
|
||||
<p className="py-4 text-center text-muted-foreground">
|
||||
No log entries
|
||||
</p>
|
||||
)}
|
||||
{filteredEntries.map((entry, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5 hover:bg-muted/50">
|
||||
<span className="text-muted-foreground shrink-0">{formatTime(entry.timestamp)}</span>
|
||||
<span className={`shrink-0 w-10 uppercase font-semibold ${LEVEL_COLORS[entry.level]}`}>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-10 shrink-0 font-semibold uppercase ${LEVEL_COLORS[entry.level]}`}
|
||||
>
|
||||
{entry.level}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0">[{entry.source}]</span>
|
||||
<span className="text-foreground break-all">{entry.message}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
[{entry.source}]
|
||||
</span>
|
||||
<span className="break-all text-foreground">
|
||||
{entry.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
|
|
@ -194,7 +222,9 @@ export function DebugPage() {
|
|||
|
||||
{tab === "system" && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground">System Information</h3>
|
||||
<h3 className="font-semibold text-muted-foreground text-xs uppercase">
|
||||
System Information
|
||||
</h3>
|
||||
{systemInfo ? (
|
||||
<div className="space-y-1 text-sm">
|
||||
<Row label="OS" value={systemInfo.os} />
|
||||
|
|
@ -203,13 +233,18 @@ export function DebugPage() {
|
|||
<Row label="App Version" value={systemInfo.app_version} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||
)}
|
||||
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground pt-4">Browser / WebView</h3>
|
||||
<h3 className="pt-4 font-semibold text-muted-foreground text-xs uppercase">
|
||||
Browser / WebView
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<Row label="User Agent" value={navigator.userAgent} />
|
||||
<Row label="Device Pixel Ratio" value={String(window.devicePixelRatio)} />
|
||||
<Row
|
||||
label="Device Pixel Ratio"
|
||||
value={String(window.devicePixelRatio)}
|
||||
/>
|
||||
<Row label="GPU Renderer" value={getGpuRenderer()} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,22 +252,35 @@ export function DebugPage() {
|
|||
|
||||
{tab === "visibility" && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground">Visibility State</h3>
|
||||
<h3 className="font-semibold text-muted-foreground text-xs uppercase">
|
||||
Visibility State
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<Row
|
||||
label="Current State"
|
||||
value={document.visibilityState}
|
||||
valueClass={document.visibilityState === "visible" ? "text-green-500" : "text-yellow-500"}
|
||||
valueClass={
|
||||
document.visibilityState === "visible"
|
||||
? "text-green-500"
|
||||
: "text-yellow-500"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Window Focused"
|
||||
value={document.hasFocus() ? "Yes" : "No"}
|
||||
/>
|
||||
<Row label="Window Focused" value={document.hasFocus() ? "Yes" : "No"} />
|
||||
<Row label="Restore Events" value={String(visibilityCount)} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground pt-4">Recent Visibility Logs</h3>
|
||||
<div className="overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px] max-h-48">
|
||||
<h3 className="pt-4 font-semibold text-muted-foreground text-xs uppercase">
|
||||
Recent Visibility Logs
|
||||
</h3>
|
||||
<div className="max-h-48 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]">
|
||||
{getVisibilityLogs().map((entry, i) => (
|
||||
<div key={i} className="py-0.5">
|
||||
<span className="text-muted-foreground">{formatTime(entry.timestamp)}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>{" "}
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -244,10 +292,18 @@ export function DebugPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, valueClass }: { label: string; value: string; valueClass?: string }) {
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0 w-32">{label}:</span>
|
||||
<span className="w-32 shrink-0 text-muted-foreground">{label}:</span>
|
||||
<span className={`break-all ${valueClass ?? ""}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@ import type { FallbackProps } from "react-error-boundary";
|
|||
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background p-8">
|
||||
<div className="max-w-2xl w-full space-y-4">
|
||||
<h1 className="text-2xl font-bold text-destructive">
|
||||
<div className="w-full max-w-2xl space-y-4">
|
||||
<h1 className="font-bold text-2xl text-destructive">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
An unexpected error occurred. You can try again or reload the app.
|
||||
</p>
|
||||
|
||||
<pre className="max-h-64 overflow-auto rounded-md border bg-muted p-4 text-xs whitespace-pre-wrap">
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border bg-muted p-4 text-xs">
|
||||
{error instanceof Error
|
||||
? `${error.message}${error.stack ? `\n\n${error.stack}` : ""}`
|
||||
: String(error)}
|
||||
|
|
@ -21,14 +21,14 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={resetErrorBoundary}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
className="rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground text-sm hover:bg-primary/90"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
className="rounded-md border px-4 py-2 font-medium text-sm hover:bg-accent"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ import {
|
|||
SparklesIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleIcon,
|
||||
TerminalIcon,
|
||||
FlaskConicalIcon,
|
||||
DownloadIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
|
|
@ -80,10 +78,7 @@ export function ProjectPicker() {
|
|||
|
||||
if (wizardMode) {
|
||||
return (
|
||||
<ProjectWizard
|
||||
mode={wizardMode}
|
||||
onBack={() => setWizardMode(null)}
|
||||
/>
|
||||
<ProjectWizard mode={wizardMode} onBack={() => setWizardMode(null)} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +101,9 @@ export function ProjectPicker() {
|
|||
|
||||
{!isClaudeReady ? <ClaudeSetup /> : <EnvironmentStatus />}
|
||||
|
||||
<div className={`flex w-full gap-3 ${!isClaudeReady ? "pointer-events-none opacity-50" : ""}`}>
|
||||
<div
|
||||
className={`flex w-full gap-3 ${!isClaudeReady ? "pointer-events-none opacity-50" : ""}`}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setShowModeDialog(true)}
|
||||
size="lg"
|
||||
|
|
@ -174,9 +171,7 @@ export function ProjectPicker() {
|
|||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
How would you like to start?
|
||||
</DialogDescription>
|
||||
<DialogDescription>How would you like to start?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
|
|
@ -192,7 +187,7 @@ export function ProjectPicker() {
|
|||
Pick a template and let AI help you get started
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-foreground/8 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="rounded-full bg-foreground/8 px-2 py-0.5 font-medium text-[10px] text-muted-foreground">
|
||||
Recommended
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -238,7 +233,7 @@ function EnvironmentStatus() {
|
|||
const _finishUvInstall = useUvSetupStore((s) => s._finishInstall);
|
||||
|
||||
const [skillsStatus, setSkillsStatus] = useState<SkillsStatus | null>(null);
|
||||
const [skillsInstalling, setSkillsInstalling] = useState(false);
|
||||
const [skillsInstalling, _setSkillsInstalling] = useState(false);
|
||||
const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false);
|
||||
|
||||
const checkSkills = useCallback(async () => {
|
||||
|
|
@ -268,21 +263,24 @@ function EnvironmentStatus() {
|
|||
}, [_finishUvInstall]);
|
||||
|
||||
// Lazy load skills onboarding
|
||||
const [OnboardingComponent, setOnboardingComponent] = useState<React.ComponentType<{
|
||||
onClose: () => void;
|
||||
}> | null>(null);
|
||||
const [OnboardingComponent, setOnboardingComponent] =
|
||||
useState<React.ComponentType<{
|
||||
onClose: () => void;
|
||||
}> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSkillsOnboarding && !OnboardingComponent) {
|
||||
import("@/components/scientific-skills/scientific-skills-onboarding").then(
|
||||
(mod) => setOnboardingComponent(() => mod.ScientificSkillsOnboarding)
|
||||
import(
|
||||
"@/components/scientific-skills/scientific-skills-onboarding"
|
||||
).then((mod) =>
|
||||
setOnboardingComponent(() => mod.ScientificSkillsOnboarding),
|
||||
);
|
||||
}
|
||||
}, [showSkillsOnboarding, OnboardingComponent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col rounded-xl border border-border bg-muted/30 px-4 py-3 gap-2">
|
||||
<div className="flex w-full flex-col gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
{/* Claude Code — always ready here */}
|
||||
<StatusRow
|
||||
ok={true}
|
||||
|
|
@ -298,7 +296,7 @@ function EnvironmentStatus() {
|
|||
uvInstalling
|
||||
? "Installing..."
|
||||
: uvStatus === "ready"
|
||||
? uvVersion ?? "Installed"
|
||||
? (uvVersion ?? "Installed")
|
||||
: uvStatus === "checking"
|
||||
? "Checking..."
|
||||
: "Not installed"
|
||||
|
|
@ -325,7 +323,10 @@ function EnvironmentStatus() {
|
|||
}
|
||||
action={
|
||||
!skillsStatus?.installed && !skillsInstalling
|
||||
? { label: "Install", onClick: () => setShowSkillsOnboarding(true) }
|
||||
? {
|
||||
label: "Install",
|
||||
onClick: () => setShowSkillsOnboarding(true),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -355,7 +356,7 @@ function StatusRow({
|
|||
action?: { label: string; onClick?: () => void; loading?: boolean };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{ok ? (
|
||||
<CheckCircle2Icon className="size-3.5 shrink-0 text-foreground" />
|
||||
) : (
|
||||
|
|
@ -363,20 +364,20 @@ function StatusRow({
|
|||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm shrink-0",
|
||||
ok ? "text-foreground" : "text-muted-foreground"
|
||||
"shrink-0 text-sm",
|
||||
ok ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate min-w-0 flex-1">
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
|
||||
{detail}
|
||||
</span>
|
||||
{action && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs shrink-0"
|
||||
className="h-6 shrink-0 px-2 text-xs"
|
||||
onClick={action.onClick}
|
||||
disabled={action.loading}
|
||||
>
|
||||
|
|
@ -412,16 +413,16 @@ function VersionBadge({
|
|||
return (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/20"
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-primary text-xs transition-colors hover:bg-primary/20"
|
||||
>
|
||||
<ArrowUpCircleIcon className="size-3.5" />
|
||||
v{updateStatus.version} available — click to update
|
||||
<ArrowUpCircleIcon className="size-3.5" />v{updateStatus.version}{" "}
|
||||
available — click to update
|
||||
</button>
|
||||
);
|
||||
|
||||
case "downloading":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
Downloading... {updateStatus.percent}%
|
||||
</div>
|
||||
|
|
@ -429,7 +430,7 @@ function VersionBadge({
|
|||
|
||||
case "installing":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
Installing...
|
||||
</div>
|
||||
|
|
@ -437,7 +438,7 @@ function VersionBadge({
|
|||
|
||||
case "ready":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-3 py-1 text-xs text-green-600">
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-3 py-1 text-green-600 text-xs">
|
||||
<CheckCircle2Icon className="size-3.5" />
|
||||
Update complete — restarting...
|
||||
</div>
|
||||
|
|
@ -445,15 +446,15 @@ function VersionBadge({
|
|||
|
||||
case "checking":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
v{version} — checking for updates...
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3 animate-spin" />v{version} — checking
|
||||
for updates...
|
||||
</div>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<span>v{version}</span>
|
||||
<span className="mx-0.5">·</span>
|
||||
<button
|
||||
|
|
@ -468,7 +469,7 @@ function VersionBadge({
|
|||
|
||||
case "up-to-date":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<span>v{version}</span>
|
||||
<span className="mx-0.5">·</span>
|
||||
<button
|
||||
|
|
@ -483,7 +484,7 @@ function VersionBadge({
|
|||
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<span>v{version}</span>
|
||||
<span className="mx-0.5">·</span>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -22,15 +22,41 @@ import { useProjectStore } from "@/stores/project-store";
|
|||
import { useDocumentStore } from "@/stores/document-store";
|
||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import { exists, join } from "@/lib/tauri/fs";
|
||||
import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry";
|
||||
import {
|
||||
getTemplateById,
|
||||
getTemplateSkeleton,
|
||||
BIB_TEMPLATE,
|
||||
} from "@/lib/template-registry";
|
||||
import { TemplateGallery } from "@/components/template-gallery";
|
||||
import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md";
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function randomProjectName(): string {
|
||||
const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"];
|
||||
const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"];
|
||||
const adjectives = [
|
||||
"swift",
|
||||
"bright",
|
||||
"calm",
|
||||
"bold",
|
||||
"keen",
|
||||
"warm",
|
||||
"pure",
|
||||
"vast",
|
||||
"deep",
|
||||
"fair",
|
||||
];
|
||||
const nouns = [
|
||||
"paper",
|
||||
"draft",
|
||||
"thesis",
|
||||
"note",
|
||||
"study",
|
||||
"essay",
|
||||
"report",
|
||||
"brief",
|
||||
"folio",
|
||||
"opus",
|
||||
];
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const id = Math.random().toString(36).slice(2, 6);
|
||||
|
|
@ -52,8 +78,13 @@ export function ProjectWizard({ mode, onBack }: ProjectWizardProps) {
|
|||
if (mode === "template") {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
|
||||
<Button variant="ghost" size="icon" className="size-7 rounded-lg" onClick={onBack}>
|
||||
<div className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 items-center gap-3 border-border/60 border-b px-4 pt-[var(--titlebar-height)]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-lg"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="font-semibold text-sm">Choose a Template</span>
|
||||
|
|
@ -100,15 +131,22 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
if (lastProjectFolder) {
|
||||
setProjectFolder(lastProjectFolder);
|
||||
} else {
|
||||
homeDir().then((home) => join(home, "Documents", "ClaudePrism")).then((dir) => {
|
||||
mkdir(dir, { recursive: true }).catch(() => {});
|
||||
setProjectFolder(dir);
|
||||
}).catch(() => {});
|
||||
homeDir()
|
||||
.then((home) => join(home, "Documents", "ClaudePrism"))
|
||||
.then((dir) => {
|
||||
mkdir(dir, { recursive: true }).catch(() => {});
|
||||
setProjectFolder(dir);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleChooseFolder = useCallback(async () => {
|
||||
const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" });
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Choose Location for New Project",
|
||||
});
|
||||
if (selected) {
|
||||
setProjectFolder(selected);
|
||||
setLastProjectFolder(selected);
|
||||
|
|
@ -119,14 +157,33 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
const selected = await open({
|
||||
multiple: true,
|
||||
title: "Add Reference Files",
|
||||
filters: [{
|
||||
name: "Documents & Images",
|
||||
extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"],
|
||||
}],
|
||||
filters: [
|
||||
{
|
||||
name: "Documents & Images",
|
||||
extensions: [
|
||||
"pdf",
|
||||
"tex",
|
||||
"bib",
|
||||
"txt",
|
||||
"md",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"svg",
|
||||
"csv",
|
||||
"tsv",
|
||||
"json",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected) {
|
||||
const paths = Array.isArray(selected) ? selected : [selected];
|
||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
...paths.filter((p) => !prev.includes(p)),
|
||||
]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -150,16 +207,25 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
setIsDragOver(false);
|
||||
const paths = (event.payload as { paths: string[] }).paths;
|
||||
if (paths?.length > 0) {
|
||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
...paths.filter((p) => !prev.includes(p)),
|
||||
]);
|
||||
}
|
||||
} else if (type === "leave") {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
})
|
||||
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
|
||||
.then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisten = fn;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { cancelled = true; unlisten?.(); };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
|
|
@ -197,10 +263,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
}
|
||||
|
||||
if (purpose.trim()) {
|
||||
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
|
||||
const attachmentSection = attachmentNames.length > 0
|
||||
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
|
||||
: "";
|
||||
const attachmentNames = attachments
|
||||
.map((p) => p.split("/").pop())
|
||||
.filter(Boolean);
|
||||
const attachmentSection =
|
||||
attachmentNames.length > 0
|
||||
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
|
||||
: "";
|
||||
|
||||
const prompt = [
|
||||
`## New ${template.name} Project`,
|
||||
|
|
@ -228,7 +297,9 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
await openProject(projectPath);
|
||||
|
||||
if (attachments.length > 0) {
|
||||
await useDocumentStore.getState().importFiles(attachments, "attachments");
|
||||
await useDocumentStore
|
||||
.getState()
|
||||
.importFiles(attachments, "attachments");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create project:", err);
|
||||
|
|
@ -242,8 +313,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
|
||||
<Button variant="ghost" size="icon" className="size-7 rounded-lg" onClick={onBack}>
|
||||
<div className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 items-center gap-3 border-border/60 border-b px-4 pt-[var(--titlebar-height)]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-lg"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="font-semibold text-sm">New Document</span>
|
||||
|
|
@ -255,9 +331,12 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
{/* Purpose */}
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<label className="font-semibold text-sm">What are you writing?</label>
|
||||
<span className="font-semibold text-sm">
|
||||
What are you writing?
|
||||
</span>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
||||
Describe your document and Claude will generate tailored content.
|
||||
Describe your document and Claude will generate tailored
|
||||
content.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
|
|
@ -271,7 +350,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
</div>
|
||||
|
||||
{/* Collapsible sections */}
|
||||
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
|
||||
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
|
||||
{/* Reference files */}
|
||||
<div>
|
||||
<button
|
||||
|
|
@ -281,10 +360,10 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
|
||||
<FileTextIcon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">Reference files</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-medium text-sm">Reference files</span>
|
||||
{attachments.length > 0 && (
|
||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary">
|
||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-[10px] text-primary leading-none">
|
||||
{attachments.length}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -294,16 +373,18 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
/>
|
||||
</button>
|
||||
{refFilesOpen && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<div className="space-y-3 px-4 pb-4">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{attachments.map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 py-1 pr-1.5 pl-2.5 text-xs transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
<span className="max-w-[140px] truncate text-foreground/80">{path.split("/").pop()}</span>
|
||||
<span className="max-w-[140px] truncate text-foreground/80">
|
||||
{path.split("/").pop()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(path)}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
|
|
@ -317,23 +398,27 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
<div
|
||||
className={`flex flex-col items-center gap-2 rounded-lg border border-dashed p-4 transition-all ${
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5 scale-[1.01]"
|
||||
? "scale-[1.01] border-primary bg-primary/5"
|
||||
: "border-border/60 hover:border-border hover:bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
{isDragOver ? (
|
||||
<>
|
||||
<UploadIcon className="size-5 text-primary" />
|
||||
<span className="text-xs font-medium text-primary">Drop to add</span>
|
||||
<span className="font-medium text-primary text-xs">
|
||||
Drop to add
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon className="size-5 text-muted-foreground/40" />
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
Drag & drop or{" "}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleAddAttachments}
|
||||
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground hover:decoration-foreground/50 transition-colors"
|
||||
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground hover:decoration-foreground/50"
|
||||
>
|
||||
browse files
|
||||
</button>
|
||||
|
|
@ -357,11 +442,11 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
|
||||
<MapPinIcon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">Project location</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-medium text-sm">Project location</span>
|
||||
</div>
|
||||
{!locationOpen && projectFolder && projectName.trim() && (
|
||||
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 text-[11px] font-mono text-muted-foreground/60">
|
||||
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
||||
.../{projectFolder.split("/").pop()}/{projectName.trim()}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -370,7 +455,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
|||
/>
|
||||
</button>
|
||||
{locationOpen && (
|
||||
<div className="px-4 pb-4 space-y-2.5">
|
||||
<div className="space-y-2.5 px-4 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const PHASE_MAP: Record<string, number> = {
|
|||
"downloading tarball": 20,
|
||||
"Download complete": 60,
|
||||
"Copying skills": 70,
|
||||
"Copied": 90,
|
||||
Copied: 90,
|
||||
"Cleanup complete": 95,
|
||||
};
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ export function InstallProgress({
|
|||
<div className="space-y-2 py-1">
|
||||
<Progress value={pct} />
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-xs truncate max-w-[80%]">
|
||||
<p className="max-w-[80%] truncate text-muted-foreground text-xs">
|
||||
{label}
|
||||
</p>
|
||||
<p className="font-mono text-muted-foreground text-xs tabular-nums">
|
||||
|
|
@ -79,7 +79,10 @@ export function InstallProgress({
|
|||
</div>
|
||||
{logs.length > 0 && (
|
||||
<ScrollArea className="h-28 rounded-md border border-border/60 bg-muted/30">
|
||||
<div ref={scrollRef} className="p-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="p-2 font-mono text-[11px] text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{logs.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ export function ScientificSkillsOnboarding({
|
|||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [installResult, setInstallResult] = useState<InstallResult | null>(null);
|
||||
const [installResult, setInstallResult] = useState<InstallResult | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<SkillsStatus | null>(null);
|
||||
const [isUninstalling, setIsUninstalling] = useState(false);
|
||||
|
|
@ -95,7 +97,9 @@ export function ScientificSkillsOnboarding({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await invoke<InstallResult>("install_scientific_skills_global");
|
||||
const result = await invoke<InstallResult>(
|
||||
"install_scientific_skills_global",
|
||||
);
|
||||
setInstallResult(result);
|
||||
setIsComplete(true);
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
|
|
@ -111,7 +115,9 @@ export function ScientificSkillsOnboarding({
|
|||
try {
|
||||
await invoke("uninstall_scientific_skills", { projectPath: null });
|
||||
await checkStatus();
|
||||
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", { projectPath: null });
|
||||
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", {
|
||||
projectPath: null,
|
||||
});
|
||||
if (!gsAfter.installed) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
|
@ -125,7 +131,12 @@ export function ScientificSkillsOnboarding({
|
|||
// ─── Installing / Complete state ───
|
||||
if (isInstalling || isComplete) {
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open && (isComplete || error)) onClose(); }}>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && (isComplete || error)) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
|
|
@ -138,7 +149,8 @@ export function ScientificSkillsOnboarding({
|
|||
</DialogTitle>
|
||||
{isComplete && (
|
||||
<DialogDescription>
|
||||
{installResult?.skills_installed ?? 0} scientific skills are now available.
|
||||
{installResult?.skills_installed ?? 0} scientific skills are now
|
||||
available.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
|
@ -152,7 +164,9 @@ export function ScientificSkillsOnboarding({
|
|||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
||||
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{error}</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -161,7 +175,10 @@ export function ScientificSkillsOnboarding({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setError(null); setIsInstalling(false); }}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setIsInstalling(false);
|
||||
}}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
|
|
@ -181,18 +198,24 @@ export function ScientificSkillsOnboarding({
|
|||
|
||||
// ─── Browse state — two-column layout ───
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none w-[min(56rem,calc(100vw-4rem))] h-[min(36rem,calc(100vh-6rem))]"
|
||||
className="flex h-[min(36rem,calc(100vh-6rem))] w-[min(56rem,calc(100vw-4rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
|
||||
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="text-sm">Scientific Skills</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 text-xs">
|
||||
{totalSkills} AI skills across {categories.length} domains — powered by{" "}
|
||||
{totalSkills} AI skills across {categories.length} domains —
|
||||
powered by{" "}
|
||||
<a
|
||||
href="https://github.com/K-Dense-AI/claude-scientific-skills"
|
||||
target="_blank"
|
||||
|
|
@ -248,7 +271,7 @@ export function ScientificSkillsOnboarding({
|
|||
{/* Body — sidebar + detail */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Category sidebar */}
|
||||
<nav className="w-64 shrink-0 overflow-hidden border-r border-border">
|
||||
<nav className="w-64 shrink-0 overflow-hidden border-border border-r">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
{categories.map((cat) => {
|
||||
|
|
@ -266,8 +289,10 @@ export function ScientificSkillsOnboarding({
|
|||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{cat.name}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{cat.skill_count}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -297,11 +322,16 @@ export function ScientificSkillsOnboarding({
|
|||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/20 px-6 py-2.5">
|
||||
<p className="font-mono text-muted-foreground/60 text-[11px]">
|
||||
<div className="flex shrink-0 items-center justify-between border-border border-t bg-muted/20 px-6 py-2.5">
|
||||
<p className="font-mono text-[11px] text-muted-foreground/60">
|
||||
{isInstalled ? status?.location : "~/.claude/skills/"}
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} className="text-muted-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -320,7 +350,9 @@ function CategoryDetail({
|
|||
isInstalled: boolean;
|
||||
}) {
|
||||
const Icon = ICON_MAP[category.icon] || FlaskConicalIcon;
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(null);
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(
|
||||
null,
|
||||
);
|
||||
const [skillContent, setSkillContent] = useState<string | null>(null);
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
|
@ -364,8 +396,12 @@ function CategoryDetail({
|
|||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => { setSelectedSkill(null); setSkillContent(null); setFetchError(null); }}
|
||||
className="mb-3 flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSelectedSkill(null);
|
||||
setSkillContent(null);
|
||||
setFetchError(null);
|
||||
}}
|
||||
className="mb-3 flex items-center gap-1 text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronLeftIcon className="size-3.5" />
|
||||
{category.name}
|
||||
|
|
@ -386,17 +422,19 @@ function CategoryDetail({
|
|||
<Separator className="my-4" />
|
||||
|
||||
{loadingContent ? (
|
||||
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
Loading skill content…
|
||||
</div>
|
||||
) : fetchError ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
||||
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{fetchError}</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{fetchError}
|
||||
</p>
|
||||
</div>
|
||||
) : skillContent ? (
|
||||
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-xs leading-relaxed text-foreground/80">
|
||||
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-foreground/80 text-xs leading-relaxed">
|
||||
{skillContent}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ import {
|
|||
type TemplateCategory,
|
||||
type TemplateSubcategory,
|
||||
CATEGORY_LABELS,
|
||||
SUBCATEGORY_LABELS,
|
||||
CATEGORY_SUBCATEGORIES,
|
||||
getCategories,
|
||||
getAllTemplates,
|
||||
getTemplatesByCategory,
|
||||
|
|
@ -32,7 +30,7 @@ const CATEGORY_ICONS: Record<TemplateCategory, React.ReactNode> = {
|
|||
starter: <SparklesIcon className="size-4" />,
|
||||
};
|
||||
|
||||
const SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
|
||||
const _SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
|
||||
papers: <FileTextIcon className="size-3.5" />,
|
||||
theses: <GraduationCapIcon className="size-3.5" />,
|
||||
presentations: <MonitorIcon className="size-3.5" />,
|
||||
|
|
@ -63,7 +61,9 @@ export function CategorySidebar() {
|
|||
>
|
||||
<SparklesIcon className="size-4" />
|
||||
<span className="flex-1">All Templates</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{allCount}</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{allCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1.5 h-px bg-border" />
|
||||
|
|
@ -84,7 +84,9 @@ export function CategorySidebar() {
|
|||
>
|
||||
{CATEGORY_ICONS[cat]}
|
||||
<span className="flex-1">{CATEGORY_LABELS[cat]}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,20 @@ import {
|
|||
export function ThumbnailPaper({ color }: { color: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center px-4 py-3">
|
||||
<div className="mb-1.5 h-1.5 w-12 rounded-full" style={{ backgroundColor: color }} />
|
||||
<div
|
||||
className="mb-1.5 h-1.5 w-12 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<div className="mb-3 h-1 w-8 rounded-full bg-muted-foreground/20" />
|
||||
<div className="mb-2 w-full rounded-sm bg-muted-foreground/8 p-1.5">
|
||||
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
|
||||
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
|
||||
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/15" />
|
||||
</div>
|
||||
<div className="mb-1.5 h-1 w-10 self-start rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
|
||||
<div
|
||||
className="mb-1.5 h-1 w-10 self-start rounded-full"
|
||||
style={{ backgroundColor: color, opacity: 0.6 }}
|
||||
/>
|
||||
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
||||
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
||||
<div className="mb-1 h-0.5 w-11/12 rounded-full bg-muted-foreground/12" />
|
||||
|
|
@ -35,11 +41,17 @@ export function ThumbnailSlides({ color }: { color: string }) {
|
|||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 px-3 py-2">
|
||||
<div className="flex w-full flex-1 flex-col items-center justify-center rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
|
||||
<div className="mb-0.5 h-1 w-10 rounded-full" style={{ backgroundColor: color }} />
|
||||
<div
|
||||
className="mb-0.5 h-1 w-10 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<div className="h-0.5 w-6 rounded-full bg-muted-foreground/20" />
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
|
||||
<div className="mb-0.5 h-0.5 w-6 rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
|
||||
<div
|
||||
className="mb-0.5 h-0.5 w-6 rounded-full"
|
||||
style={{ backgroundColor: color, opacity: 0.6 }}
|
||||
/>
|
||||
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
|
||||
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
|
||||
</div>
|
||||
|
|
@ -47,7 +59,7 @@ export function ThumbnailSlides({ color }: { color: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function ThumbnailPoster({ color }: { color: string }) {
|
||||
export function ThumbnailPoster({ color: _color }: { color: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col px-2 py-2">
|
||||
<div className="mb-2 h-1 w-10 self-center rounded-full bg-muted-foreground/20" />
|
||||
|
|
@ -70,7 +82,7 @@ export function ThumbnailPoster({ color }: { color: string }) {
|
|||
export function ThumbnailBlank(_props: { color: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-muted-foreground/20 text-xs font-medium">Empty</div>
|
||||
<div className="font-medium text-muted-foreground/20 text-xs">Empty</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -86,11 +98,13 @@ export const THUMBNAIL_MAP: Record<string, React.FC<{ color: string }>> = {
|
|||
"letter-formal": ThumbnailPaper,
|
||||
"report-technical": ThumbnailPaper,
|
||||
"book-standard": ThumbnailPaper,
|
||||
"newsletter": ThumbnailPaper,
|
||||
"blank": ThumbnailBlank,
|
||||
newsletter: ThumbnailPaper,
|
||||
blank: ThumbnailBlank,
|
||||
};
|
||||
|
||||
export function getFallbackThumbnail(template: TemplateDefinition): React.FC<{ color: string }> {
|
||||
export function getFallbackThumbnail(
|
||||
template: TemplateDefinition,
|
||||
): React.FC<{ color: string }> {
|
||||
return THUMBNAIL_MAP[template.id] || ThumbnailPaper;
|
||||
}
|
||||
|
||||
|
|
@ -104,14 +118,12 @@ export function TemplateCard({ template }: TemplateCardProps) {
|
|||
const openPreview = useTemplateStore((s) => s.openPreview);
|
||||
const FallbackThumbnail = getFallbackThumbnail(template);
|
||||
|
||||
const thumbnailUrl = useSyncExternalStore(
|
||||
subscribeThumbnails,
|
||||
() => getThumbnail(template.id),
|
||||
const thumbnailUrl = useSyncExternalStore(subscribeThumbnails, () =>
|
||||
getThumbnail(template.id),
|
||||
);
|
||||
|
||||
const failed = useSyncExternalStore(
|
||||
subscribeThumbnails,
|
||||
() => isThumbnailFailed(template.id),
|
||||
const failed = useSyncExternalStore(subscribeThumbnails, () =>
|
||||
isThumbnailFailed(template.id),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -149,7 +161,7 @@ export function TemplateCard({ template }: TemplateCardProps) {
|
|||
</div>
|
||||
<div className="mt-2 px-0.5">
|
||||
<div className="font-medium text-sm leading-tight">{template.name}</div>
|
||||
<div className="mt-0.5 text-muted-foreground text-xs leading-snug line-clamp-2">
|
||||
<div className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-snug">
|
||||
{template.description}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function TemplateGallery() {
|
|||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Search bar */}
|
||||
<div className="shrink-0 border-b border-border px-4 py-3">
|
||||
<div className="shrink-0 border-border border-b px-4 py-3">
|
||||
<div className="relative mx-auto max-w-xl">
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
|
|
@ -61,7 +61,7 @@ export function TemplateGallery() {
|
|||
placeholder="Search templates... ⌘K"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-8"
|
||||
className="pr-8 pl-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
|
|
@ -77,7 +77,7 @@ export function TemplateGallery() {
|
|||
{/* Main content: sidebar + grid */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Category sidebar */}
|
||||
<div className="shrink-0 border-r border-border pl-3 pt-2">
|
||||
<div className="shrink-0 border-border border-r pt-2 pl-3">
|
||||
<CategorySidebar />
|
||||
</div>
|
||||
|
||||
|
|
@ -86,8 +86,10 @@ export function TemplateGallery() {
|
|||
{filteredTemplates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<SearchIcon className="mb-3 size-8 text-muted-foreground/40" />
|
||||
<p className="font-medium text-sm text-muted-foreground">No templates found</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
No templates found
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground/70 text-xs">
|
||||
Try a different search term or category
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -95,7 +97,9 @@ export function TemplateGallery() {
|
|||
<GroupedGrid />
|
||||
) : (
|
||||
<>
|
||||
<h2 className="mb-4 font-medium text-sm text-muted-foreground">{heading}</h2>
|
||||
<h2 className="mb-4 font-medium text-muted-foreground text-sm">
|
||||
{heading}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{filteredTemplates.map((t) => (
|
||||
<TemplateCard key={t.id} template={t} />
|
||||
|
|
@ -118,7 +122,12 @@ function GroupedGrid() {
|
|||
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
|
||||
|
||||
// Group by category preserving order
|
||||
const categories: TemplateCategory[] = ["academic", "professional", "creative", "starter"];
|
||||
const categories: TemplateCategory[] = [
|
||||
"academic",
|
||||
"professional",
|
||||
"creative",
|
||||
"starter",
|
||||
];
|
||||
const groups = categories
|
||||
.map((cat) => ({
|
||||
category: cat,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,11 @@ import { useTemplateStore } from "@/stores/template-store";
|
|||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useDocumentStore } from "@/stores/document-store";
|
||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry";
|
||||
import {
|
||||
getTemplateById,
|
||||
getTemplateSkeleton,
|
||||
BIB_TEMPLATE,
|
||||
} from "@/lib/template-registry";
|
||||
import { getTemplatePdfUrl } from "@/lib/template-preview-cache";
|
||||
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
|
||||
import { exists, join } from "@/lib/tauri/fs";
|
||||
|
|
@ -44,8 +48,30 @@ const log = createLogger("template-preview");
|
|||
// ─── Helpers ───
|
||||
|
||||
function randomProjectName(): string {
|
||||
const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"];
|
||||
const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"];
|
||||
const adjectives = [
|
||||
"swift",
|
||||
"bright",
|
||||
"calm",
|
||||
"bold",
|
||||
"keen",
|
||||
"warm",
|
||||
"pure",
|
||||
"vast",
|
||||
"deep",
|
||||
"fair",
|
||||
];
|
||||
const nouns = [
|
||||
"paper",
|
||||
"draft",
|
||||
"thesis",
|
||||
"note",
|
||||
"study",
|
||||
"essay",
|
||||
"report",
|
||||
"brief",
|
||||
"folio",
|
||||
"opus",
|
||||
];
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const id = Math.random().toString(36).slice(2, 6);
|
||||
|
|
@ -59,7 +85,9 @@ type ModalStep = "preview" | "details";
|
|||
export function TemplatePreview() {
|
||||
const previewTemplateId = useTemplateStore((s) => s.previewTemplateId);
|
||||
const closePreview = useTemplateStore((s) => s.closePreview);
|
||||
const template = previewTemplateId ? getTemplateById(previewTemplateId) : null;
|
||||
const template = previewTemplateId
|
||||
? getTemplateById(previewTemplateId)
|
||||
: null;
|
||||
|
||||
const [modalStep, setModalStep] = useState<ModalStep>("preview");
|
||||
|
||||
|
|
@ -103,7 +131,9 @@ export function TemplatePreview() {
|
|||
setIsLandscape(false);
|
||||
setError(false);
|
||||
if (docIdRef.current > 0) {
|
||||
getMupdfClient().closeDocument(docIdRef.current).catch(() => {});
|
||||
getMupdfClient()
|
||||
.closeDocument(docIdRef.current)
|
||||
.catch(() => {});
|
||||
docIdRef.current = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -129,7 +159,9 @@ export function TemplatePreview() {
|
|||
if (lastProjectFolder) {
|
||||
setProjectFolder(lastProjectFolder);
|
||||
} else {
|
||||
documentDir().then((dir) => setProjectFolder(dir)).catch(() => {});
|
||||
documentDir()
|
||||
.then((dir) => setProjectFolder(dir))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
|
@ -199,7 +231,13 @@ export function TemplatePreview() {
|
|||
|
||||
// ── Render current page ──
|
||||
useEffect(() => {
|
||||
if (docIdRef.current <= 0 || numPages === 0 || !canvasRef.current || !containerRef.current) return;
|
||||
if (
|
||||
docIdRef.current <= 0 ||
|
||||
numPages === 0 ||
|
||||
!canvasRef.current ||
|
||||
!containerRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
const pageIndex = currentPage - 1;
|
||||
const size = pageSizesRef.current[pageIndex];
|
||||
|
|
@ -223,18 +261,21 @@ export function TemplatePreview() {
|
|||
const dpi = (displayW / size.width) * 72 * dpr;
|
||||
|
||||
const client = getMupdfClient();
|
||||
client.drawPage(docIdRef.current, pageIndex, dpi).then((imageData) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
canvas.style.width = `${displayW}px`;
|
||||
canvas.style.height = `${displayH}px`;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}).catch((err) => {
|
||||
log.warn("render error", { error: String(err) });
|
||||
});
|
||||
client
|
||||
.drawPage(docIdRef.current, pageIndex, dpi)
|
||||
.then((imageData) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
canvas.style.width = `${displayW}px`;
|
||||
canvas.style.height = `${displayH}px`;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn("render error", { error: String(err) });
|
||||
});
|
||||
}, [currentPage, numPages, isLandscape]);
|
||||
|
||||
// ── Page navigation ──
|
||||
|
|
@ -251,8 +292,13 @@ export function TemplatePreview() {
|
|||
if (!previewTemplateId || modalStep !== "preview") return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); goToPrevPage(); }
|
||||
else if (e.key === "ArrowRight") { e.preventDefault(); goToNextPage(); }
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
goToPrevPage();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
goToNextPage();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
|
|
@ -275,16 +321,25 @@ export function TemplatePreview() {
|
|||
setIsDragOver(false);
|
||||
const paths = (event.payload as { paths: string[] }).paths;
|
||||
if (paths?.length > 0) {
|
||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
...paths.filter((p) => !prev.includes(p)),
|
||||
]);
|
||||
}
|
||||
} else if (type === "leave") {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
})
|
||||
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
|
||||
.then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisten = fn;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { cancelled = true; unlisten?.(); };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [modalStep]);
|
||||
|
||||
// ── File handlers ──
|
||||
|
|
@ -292,14 +347,33 @@ export function TemplatePreview() {
|
|||
const selected = await open({
|
||||
multiple: true,
|
||||
title: "Add Reference Files",
|
||||
filters: [{
|
||||
name: "Documents & Images",
|
||||
extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"],
|
||||
}],
|
||||
filters: [
|
||||
{
|
||||
name: "Documents & Images",
|
||||
extensions: [
|
||||
"pdf",
|
||||
"tex",
|
||||
"bib",
|
||||
"txt",
|
||||
"md",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"svg",
|
||||
"csv",
|
||||
"tsv",
|
||||
"json",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected) {
|
||||
const paths = Array.isArray(selected) ? selected : [selected];
|
||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
...paths.filter((p) => !prev.includes(p)),
|
||||
]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -308,7 +382,11 @@ export function TemplatePreview() {
|
|||
};
|
||||
|
||||
const handleChooseFolder = useCallback(async () => {
|
||||
const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" });
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Choose Location for New Project",
|
||||
});
|
||||
if (selected) {
|
||||
setProjectFolder(selected);
|
||||
setLastProjectFolder(selected);
|
||||
|
|
@ -344,10 +422,13 @@ export function TemplatePreview() {
|
|||
}
|
||||
|
||||
if (purpose.trim()) {
|
||||
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
|
||||
const attachmentSection = attachmentNames.length > 0
|
||||
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
|
||||
: "";
|
||||
const attachmentNames = attachments
|
||||
.map((p) => p.split("/").pop())
|
||||
.filter(Boolean);
|
||||
const attachmentSection =
|
||||
attachmentNames.length > 0
|
||||
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
|
||||
: "";
|
||||
|
||||
const prompt = [
|
||||
`## New ${template.name} Project`,
|
||||
|
|
@ -375,7 +456,9 @@ export function TemplatePreview() {
|
|||
await openProject(projectPath);
|
||||
|
||||
if (attachments.length > 0) {
|
||||
await useDocumentStore.getState().importFiles(attachments, "attachments");
|
||||
await useDocumentStore
|
||||
.getState()
|
||||
.importFiles(attachments, "attachments");
|
||||
}
|
||||
|
||||
// Close modal on success
|
||||
|
|
@ -392,28 +475,30 @@ export function TemplatePreview() {
|
|||
if (!template) return null;
|
||||
|
||||
// ── Modal width depends on step ──
|
||||
const modalWidth = modalStep === "preview"
|
||||
? isLandscape
|
||||
? "w-[min(72rem,calc(100vw-4rem))]"
|
||||
: "w-[min(48rem,calc(100vw-6rem))]"
|
||||
: "w-[min(32rem,calc(100vw-4rem))]";
|
||||
const modalWidth =
|
||||
modalStep === "preview"
|
||||
? isLandscape
|
||||
? "w-[min(72rem,calc(100vw-4rem))]"
|
||||
: "w-[min(48rem,calc(100vw-6rem))]"
|
||||
: "w-[min(32rem,calc(100vw-4rem))]";
|
||||
|
||||
return (
|
||||
<Dialog open={!!previewTemplateId} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={`flex max-w-none sm:max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
|
||||
className={`flex max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 sm:max-w-none ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
|
||||
>
|
||||
{modalStep === "preview" ? (
|
||||
/* ═══════════════════ PREVIEW STEP ═══════════════════ */
|
||||
<>
|
||||
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
|
||||
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="text-sm">{template.name}</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||
{template.description} — {template.documentClass}
|
||||
{template.packages.length > 0 && ` — ${template.packages.length} packages`}
|
||||
{template.packages.length > 0 &&
|
||||
` — ${template.packages.length} packages`}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
|
@ -431,7 +516,10 @@ export function TemplatePreview() {
|
|||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="relative flex flex-1 flex-col">
|
||||
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-6">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-6"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
|
|
@ -452,14 +540,26 @@ export function TemplatePreview() {
|
|||
</div>
|
||||
|
||||
{numPages > 0 && (
|
||||
<div className="flex shrink-0 items-center justify-center gap-3 border-t border-border bg-background py-2.5">
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={goToPrevPage} disabled={currentPage <= 1}>
|
||||
<div className="flex shrink-0 items-center justify-center gap-3 border-border border-t bg-background py-2.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={goToPrevPage}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="min-w-16 text-center text-xs tabular-nums text-muted-foreground">
|
||||
<span className="min-w-16 text-center text-muted-foreground text-xs tabular-nums">
|
||||
{numPages > 1 ? `${currentPage} / ${numPages}` : "1 page"}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={goToNextPage} disabled={currentPage >= numPages}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage >= numPages}
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -471,12 +571,12 @@ export function TemplatePreview() {
|
|||
/* ═══════════════════ DETAILS STEP ═══════════════════ */
|
||||
<>
|
||||
{/* Header */}
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-3">
|
||||
<DialogHeader className="shrink-0 border-border/60 border-b px-5 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-lg shrink-0"
|
||||
className="size-7 shrink-0 rounded-lg"
|
||||
onClick={() => setModalStep("preview")}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
|
|
@ -496,9 +596,12 @@ export function TemplatePreview() {
|
|||
{/* Purpose — hero element */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="font-semibold text-sm">What are you writing?</label>
|
||||
<span className="font-semibold text-sm">
|
||||
What are you writing?
|
||||
</span>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
||||
Describe your document and Claude will generate tailored content.
|
||||
Describe your document and Claude will generate tailored
|
||||
content.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
|
|
@ -512,7 +615,7 @@ export function TemplatePreview() {
|
|||
</div>
|
||||
|
||||
{/* Collapsible sections */}
|
||||
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
|
||||
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
|
||||
{/* Reference files */}
|
||||
<div>
|
||||
<button
|
||||
|
|
@ -522,10 +625,12 @@ export function TemplatePreview() {
|
|||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
|
||||
<FileTextIcon className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">Reference files</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-medium text-sm">
|
||||
Reference files
|
||||
</span>
|
||||
{attachments.length > 0 && (
|
||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary">
|
||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-[10px] text-primary leading-none">
|
||||
{attachments.length}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -535,16 +640,18 @@ export function TemplatePreview() {
|
|||
/>
|
||||
</button>
|
||||
{refFilesOpen && (
|
||||
<div className="px-4 pb-3 space-y-2.5">
|
||||
<div className="space-y-2.5 px-4 pb-3">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{attachments.map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 py-1 pr-1.5 pl-2.5 text-xs transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
<span className="max-w-30 truncate text-foreground/80">{path.split("/").pop()}</span>
|
||||
<span className="max-w-30 truncate text-foreground/80">
|
||||
{path.split("/").pop()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(path)}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
|
|
@ -565,16 +672,20 @@ export function TemplatePreview() {
|
|||
{isDragOver ? (
|
||||
<>
|
||||
<UploadIcon className="size-4 text-primary" />
|
||||
<span className="text-xs font-medium text-primary">Drop to add</span>
|
||||
<span className="font-medium text-primary text-xs">
|
||||
Drop to add
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon className="size-4 text-muted-foreground/40" />
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
Drag & drop or{" "}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleAddAttachments}
|
||||
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground transition-colors"
|
||||
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground"
|
||||
>
|
||||
browse files
|
||||
</button>
|
||||
|
|
@ -595,12 +706,15 @@ export function TemplatePreview() {
|
|||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
|
||||
<MapPinIcon className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">Project location</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-medium text-sm">
|
||||
Project location
|
||||
</span>
|
||||
</div>
|
||||
{!locationOpen && projectFolder && projectName.trim() && (
|
||||
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 text-[11px] font-mono text-muted-foreground/60">
|
||||
.../{projectFolder.split("/").pop()}/{projectName.trim()}
|
||||
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
||||
.../{projectFolder.split("/").pop()}/
|
||||
{projectName.trim()}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
|
|
@ -608,7 +722,7 @@ export function TemplatePreview() {
|
|||
/>
|
||||
</button>
|
||||
{locationOpen && (
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
<div className="space-y-2 px-4 pb-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
|
|
@ -639,7 +753,7 @@ export function TemplatePreview() {
|
|||
</div>
|
||||
|
||||
{/* Create button — sticky footer */}
|
||||
<div className="shrink-0 border-t border-border/60 px-5 py-4">
|
||||
<div className="shrink-0 border-border/60 border-t px-5 py-4">
|
||||
<Button
|
||||
className="w-full gap-2 rounded-xl font-semibold shadow-sm transition-all hover:shadow-md active:scale-[0.99]"
|
||||
size="lg"
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center rounded-md border px-2 py-0.5 font-medium text-xs transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground",
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ function ContextMenuTrigger({
|
|||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import * as React from "react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import * as React from "react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import * as React from "react";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import * as React from "react";
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import * as React from "react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function TabsTrigger({
|
|||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 font-medium text-sm ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import * as React from "react";
|
||||
import { Toggle as TogglePrimitive } from "radix-ui";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import * as React from "react";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
{/* uv status */}
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<StatusIcon status={status} isInstalling={isInstalling} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm">
|
||||
{status === "checking"
|
||||
? "Checking uv..."
|
||||
|
|
@ -97,12 +97,12 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
: "Error"}
|
||||
</div>
|
||||
{version && (
|
||||
<div className="text-muted-foreground text-xs truncate">
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{version}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-destructive text-xs mt-1">{error}</div>
|
||||
<div className="mt-1 text-destructive text-xs">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
{status === "not-installed" && !isInstalling && (
|
||||
|
|
@ -127,23 +127,33 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
"flex size-8 items-center justify-center rounded-full",
|
||||
venvReady
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm">
|
||||
{venvReady ? "Virtual Environment Active" : "No Virtual Environment"}
|
||||
{venvReady
|
||||
? "Virtual Environment Active"
|
||||
: "No Virtual Environment"}
|
||||
</div>
|
||||
{venvPath && (
|
||||
<div className="text-muted-foreground text-xs truncate" title={venvPath}>
|
||||
<div
|
||||
className="truncate text-muted-foreground text-xs"
|
||||
title={venvPath}
|
||||
>
|
||||
{venvPath}
|
||||
</div>
|
||||
)}
|
||||
{pythonPath && (
|
||||
<div className="text-muted-foreground text-xs truncate" title={pythonPath}>
|
||||
Python: {pythonPath.split("/").pop() || pythonPath.split("\\").pop()}
|
||||
<div
|
||||
className="truncate text-muted-foreground text-xs"
|
||||
title={pythonPath}
|
||||
>
|
||||
Python:{" "}
|
||||
{pythonPath.split("/").pop() ||
|
||||
pythonPath.split("\\").pop()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -159,8 +169,9 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
{status === "ready" && venvReady && (
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
Claude Code will automatically use this environment when running
|
||||
Python code. Use <code className="text-foreground">uv pip install</code> to
|
||||
add packages.
|
||||
Python code. Use{" "}
|
||||
<code className="text-foreground">uv pip install</code> to add
|
||||
packages.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ export function EditorToolbar({
|
|||
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<EditorInfo[]>("detect_editors").then(setEditors).catch(() => {});
|
||||
invoke<EditorInfo[]>("detect_editors")
|
||||
.then(setEditors)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const openInEditor = useCallback(
|
||||
|
|
@ -130,7 +132,7 @@ export function EditorToolbar({
|
|||
|
||||
if (fileType === "image") {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
|
||||
<div className="flex h-[calc(36px+var(--titlebar-height))] items-center justify-between border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<ImageIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
|
|
@ -224,7 +226,7 @@ export function EditorToolbar({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
|
||||
<div className="flex h-[calc(36px+var(--titlebar-height))] items-center gap-1 border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)]">
|
||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||
<span className="mr-2 font-medium text-muted-foreground text-sm">
|
||||
{fileName}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ImageIcon, CheckIcon, XIcon } from "lucide-react";
|
||||
import { CheckIcon, XIcon } from "lucide-react";
|
||||
import { writeFile } from "@tauri-apps/plugin-fs";
|
||||
import { toast } from "sonner";
|
||||
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
||||
|
|
@ -50,9 +50,17 @@ export function ImagePreview({
|
|||
|
||||
// Crop state
|
||||
const [cropRect, setCropRect] = useState<CropRect | null>(null);
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [activeHandle, setActiveHandle] = useState<HandleId | "move" | null>(null);
|
||||
const [handleDragStart, setHandleDragStart] = useState<{ x: number; y: number; rect: CropRect } | null>(null);
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [activeHandle, setActiveHandle] = useState<HandleId | "move" | null>(
|
||||
null,
|
||||
);
|
||||
const [handleDragStart, setHandleDragStart] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
rect: CropRect;
|
||||
} | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Reset crop rect when exiting crop mode
|
||||
|
|
@ -121,18 +129,15 @@ export function ImagePreview({
|
|||
}, [scale, onScaleChange, cropMode]);
|
||||
|
||||
// Get coordinates relative to the displayed image
|
||||
const getImageRelativeCoords = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return null;
|
||||
const rect = img.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
|
||||
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
const getImageRelativeCoords = useCallback((e: React.MouseEvent) => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return null;
|
||||
const rect = img.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
|
||||
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- Crop drag: new selection ---
|
||||
const handleCropMouseDown = useCallback(
|
||||
|
|
@ -151,7 +156,11 @@ export function ImagePreview({
|
|||
coords.y <= cropRect.y + cropRect.h
|
||||
) {
|
||||
setActiveHandle("move");
|
||||
setHandleDragStart({ x: coords.x, y: coords.y, rect: { ...cropRect } });
|
||||
setHandleDragStart({
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
rect: { ...cropRect },
|
||||
});
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
|
@ -230,11 +239,21 @@ export function ImagePreview({
|
|||
setCropRect({ x, y, w, h });
|
||||
}
|
||||
},
|
||||
[cropMode, dragStart, activeHandle, handleDragStart, getImageRelativeCoords],
|
||||
[
|
||||
cropMode,
|
||||
dragStart,
|
||||
activeHandle,
|
||||
handleDragStart,
|
||||
getImageRelativeCoords,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCropMouseUp = useCallback(() => {
|
||||
if (dragStart && cropRect && (cropRect.w < MIN_CROP_SIZE || cropRect.h < MIN_CROP_SIZE)) {
|
||||
if (
|
||||
dragStart &&
|
||||
cropRect &&
|
||||
(cropRect.w < MIN_CROP_SIZE || cropRect.h < MIN_CROP_SIZE)
|
||||
) {
|
||||
setCropRect(null);
|
||||
}
|
||||
setDragStart(null);
|
||||
|
|
@ -328,14 +347,18 @@ export function ImagePreview({
|
|||
// Use dataUrl if available (in-memory), otherwise fall back to asset URL (large images)
|
||||
const imageSrc = file.dataUrl || getAssetUrl(file.absolutePath);
|
||||
// Crop requires dataUrl (canvas manipulation needs same-origin data)
|
||||
const canCrop = !!file.dataUrl;
|
||||
const _canCrop = !!file.dataUrl;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
className="relative h-full overflow-auto bg-muted/50 p-4 outline-none"
|
||||
style={cropMode ? { cursor: cropRect && !dragStart ? "default" : "crosshair" } : undefined}
|
||||
style={
|
||||
cropMode
|
||||
? { cursor: cropRect && !dragStart ? "default" : "crosshair" }
|
||||
: undefined
|
||||
}
|
||||
onMouseMove={cropMode ? handleCropMouseMove : undefined}
|
||||
onMouseUp={cropMode ? handleCropMouseUp : undefined}
|
||||
onMouseLeave={cropMode ? handleCropMouseUp : undefined}
|
||||
|
|
@ -348,7 +371,10 @@ export function ImagePreview({
|
|||
)}
|
||||
|
||||
{/* Wrapper width = scale * 100% of container → CSS handles fit, no JS needed */}
|
||||
<div className="relative" style={{ width: `${scale * 100}%`, margin: "0 auto" }}>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: `${scale * 100}%`, margin: "0 auto" }}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageSrc}
|
||||
|
|
@ -363,13 +389,40 @@ export function ImagePreview({
|
|||
<>
|
||||
{/* Dark overlay: 4 divs around the crop area */}
|
||||
{/* Top */}
|
||||
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: 0, right: 0, height: cropRect.y }} />
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 bg-black/50"
|
||||
style={{ left: 0, top: 0, right: 0, height: cropRect.y }}
|
||||
/>
|
||||
{/* Bottom */}
|
||||
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: cropRect.y + cropRect.h, right: 0, bottom: 0 }} />
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 bg-black/50"
|
||||
style={{
|
||||
left: 0,
|
||||
top: cropRect.y + cropRect.h,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
{/* Left */}
|
||||
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: cropRect.y, width: cropRect.x, height: cropRect.h }} />
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 bg-black/50"
|
||||
style={{
|
||||
left: 0,
|
||||
top: cropRect.y,
|
||||
width: cropRect.x,
|
||||
height: cropRect.h,
|
||||
}}
|
||||
/>
|
||||
{/* Right */}
|
||||
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: cropRect.x + cropRect.w, top: cropRect.y, right: 0, height: cropRect.h }} />
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 bg-black/50"
|
||||
style={{
|
||||
left: cropRect.x + cropRect.w,
|
||||
top: cropRect.y,
|
||||
right: 0,
|
||||
height: cropRect.h,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Crop border */}
|
||||
<div
|
||||
|
|
@ -388,7 +441,11 @@ export function ImagePreview({
|
|||
const coords = getImageRelativeCoords(e);
|
||||
if (!coords) return;
|
||||
setActiveHandle("move");
|
||||
setHandleDragStart({ x: coords.x, y: coords.y, rect: { ...cropRect } });
|
||||
setHandleDragStart({
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
rect: { ...cropRect },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* Resize handles */}
|
||||
|
|
@ -397,8 +454,18 @@ export function ImagePreview({
|
|||
key={h.id}
|
||||
className="absolute z-20 size-2.5 rounded-sm border border-gray-400 bg-white shadow-sm"
|
||||
style={{
|
||||
left: h.x === 0 ? -5 : h.x === 0.5 ? "calc(50% - 5px)" : "calc(100% - 5px)",
|
||||
top: h.y === 0 ? -5 : h.y === 0.5 ? "calc(50% - 5px)" : "calc(100% - 5px)",
|
||||
left:
|
||||
h.x === 0
|
||||
? -5
|
||||
: h.x === 0.5
|
||||
? "calc(50% - 5px)"
|
||||
: "calc(100% - 5px)",
|
||||
top:
|
||||
h.y === 0
|
||||
? -5
|
||||
: h.y === 0.5
|
||||
? "calc(50% - 5px)"
|
||||
: "calc(100% - 5px)",
|
||||
cursor: h.cursor,
|
||||
}}
|
||||
onMouseDown={(e) => handleHandleMouseDown(e, h.id)}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@ import { tags } from "@lezer/highlight";
|
|||
|
||||
interface BibState {
|
||||
/** Current parsing context */
|
||||
context: "top" | "entryType" | "citationKey" | "fields" | "fieldName" | "fieldSep" | "fieldValue" | "comment";
|
||||
context:
|
||||
| "top"
|
||||
| "entryType"
|
||||
| "citationKey"
|
||||
| "fields"
|
||||
| "fieldName"
|
||||
| "fieldSep"
|
||||
| "fieldValue"
|
||||
| "comment";
|
||||
/** Brace nesting depth inside a field value */
|
||||
braceDepth: number;
|
||||
/** Whether currently inside a quoted string value */
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,6 @@ import {
|
|||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
MessageSquareIcon,
|
||||
SparklesIcon,
|
||||
MousePointerClickIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
|
|
@ -31,7 +30,9 @@ function SeverityIcon({ severity }: { severity: string }) {
|
|||
case "error":
|
||||
return <AlertCircleIcon className="size-3.5 shrink-0 text-red-400" />;
|
||||
case "warning":
|
||||
return <AlertTriangleIcon className="size-3.5 shrink-0 text-yellow-400" />;
|
||||
return (
|
||||
<AlertTriangleIcon className="size-3.5 shrink-0 text-yellow-400" />
|
||||
);
|
||||
default:
|
||||
return <InfoIcon className="size-3.5 shrink-0 text-blue-400" />;
|
||||
}
|
||||
|
|
@ -47,7 +48,9 @@ export function ProblemsPanel({
|
|||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const errorCount = diagnostics.filter((d) => d.severity === "error").length;
|
||||
const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
|
||||
const warningCount = diagnostics.filter(
|
||||
(d) => d.severity === "warning",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="border-border border-t bg-background">
|
||||
|
|
@ -55,7 +58,7 @@ export function ProblemsPanel({
|
|||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="flex flex-1 items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"
|
||||
className="flex flex-1 items-center gap-2 px-3 py-1.5 text-xs transition-colors hover:bg-muted/50"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
|
||||
|
|
@ -81,7 +84,7 @@ export function ProblemsPanel({
|
|||
{diagnostics.length > 0 && onFixAllWithChat && (
|
||||
<button
|
||||
onClick={onFixAllWithChat}
|
||||
className="flex items-center gap-1.5 mx-3 my-2 px-2.5 py-1 rounded-md text-xs font-medium text-primary-foreground bg-primary hover:bg-primary/90 transition-colors shadow-sm"
|
||||
className="mx-3 my-2 flex items-center gap-1.5 rounded-md bg-primary px-2.5 py-1 font-medium text-primary-foreground text-xs shadow-sm transition-colors hover:bg-primary/90"
|
||||
title="Fix all problems with AI"
|
||||
>
|
||||
<MousePointerClickIcon className="size-3" />
|
||||
|
|
@ -96,7 +99,7 @@ export function ProblemsPanel({
|
|||
{diagnostics.map((d, i) => (
|
||||
<div
|
||||
key={`${d.from}-${d.message}-${i}`}
|
||||
className="group flex items-center gap-2 px-3 py-1 text-xs hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
className="group flex cursor-pointer items-center gap-2 px-3 py-1 text-xs transition-colors hover:bg-muted/50"
|
||||
onClick={() => onNavigate(d.from)}
|
||||
>
|
||||
<SeverityIcon severity={d.severity} />
|
||||
|
|
@ -111,7 +114,7 @@ export function ProblemsPanel({
|
|||
e.stopPropagation();
|
||||
onFixWithChat(d.message, d.line);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-muted hover:text-foreground transition-all"
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover:opacity-100"
|
||||
title="Fix with chat"
|
||||
>
|
||||
<MessageSquareIcon className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { XIcon, ChevronUpIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ArrowUpIcon } from "lucide-react";
|
||||
|
||||
export interface ToolbarAction {
|
||||
|
|
@ -94,7 +100,6 @@ export function SelectionToolbar({
|
|||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter prompt..."
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
aria-label="Send prompt"
|
||||
|
|
@ -113,12 +118,16 @@ export function SelectionToolbar({
|
|||
<button
|
||||
key={action.id}
|
||||
onClick={() => onAction(action.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-muted"
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 text-left text-foreground text-sm transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="size-4 text-muted-foreground">{action.icon}</span>
|
||||
<span className="size-4 text-muted-foreground">
|
||||
{action.icon}
|
||||
</span>
|
||||
{action.label}
|
||||
{action.hint && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{action.hint}</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{action.hint}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -127,7 +136,7 @@ export function SelectionToolbar({
|
|||
|
||||
{/* Context label */}
|
||||
<div className="border-border border-t px-3 py-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{contextLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,21 +45,25 @@ function snapshotTypeLabel(message: string): string {
|
|||
if (message.startsWith("[auto]")) return "Auto-save";
|
||||
if (message.startsWith("[manual]")) return "Save";
|
||||
if (message.startsWith("[compile]")) return "Compile";
|
||||
if (message.startsWith("[claude]")) return message.includes("Before") ? "Before Claude" : "After Claude";
|
||||
if (message.startsWith("[claude]"))
|
||||
return message.includes("Before") ? "Before Claude" : "After Claude";
|
||||
if (message.startsWith("[restore]")) return "Restore";
|
||||
if (message.startsWith("[init]")) return "Initial";
|
||||
return message;
|
||||
}
|
||||
|
||||
function snapshotTypeBadgeColor(message: string): string {
|
||||
if (message.startsWith("[claude]")) return "bg-violet-500/15 text-violet-600 dark:text-violet-400";
|
||||
if (message.startsWith("[restore]")) return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
|
||||
if (message.startsWith("[manual]")) return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
if (message.startsWith("[compile]")) return "bg-green-500/15 text-green-600 dark:text-green-400";
|
||||
if (message.startsWith("[claude]"))
|
||||
return "bg-violet-500/15 text-violet-600 dark:text-violet-400";
|
||||
if (message.startsWith("[restore]"))
|
||||
return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
|
||||
if (message.startsWith("[manual]"))
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
if (message.startsWith("[compile]"))
|
||||
return "bg-green-500/15 text-green-600 dark:text-green-400";
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
|
||||
// ─── Panel ───
|
||||
|
||||
export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
|
||||
|
|
@ -114,7 +118,9 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
|
|||
// Init history when project opens
|
||||
useEffect(() => {
|
||||
if (!projectRoot) return;
|
||||
init(projectRoot).then(() => loadSnapshots(projectRoot)).catch(console.error);
|
||||
init(projectRoot)
|
||||
.then(() => loadSnapshots(projectRoot))
|
||||
.catch(console.error);
|
||||
}, [projectRoot, init, loadSnapshots]);
|
||||
|
||||
// Infinite scroll
|
||||
|
|
@ -147,7 +153,6 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
|
|||
[projectRoot, linearSnapshots, reviewingSnapshot, loadDiff, startReview],
|
||||
);
|
||||
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (snapshotId: string) => {
|
||||
if (!projectRoot) return;
|
||||
|
|
@ -179,7 +184,9 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
|
|||
if (!projectRoot) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 px-3 py-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">Open a project to view history.</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Open a project to view history.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -199,7 +206,7 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
|
|||
onScroll={handleScroll}
|
||||
>
|
||||
{linearSnapshots.length === 0 && !isLoading ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
<div className="px-3 py-4 text-center text-muted-foreground text-xs">
|
||||
No history yet
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -213,7 +220,9 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
|
|||
onClick={() => handleClick(snap)}
|
||||
onRestore={() => handleRestore(snap.id)}
|
||||
onAddLabel={() => openLabelDialog(snap.id)}
|
||||
onRemoveLabel={(label) => projectRoot && removeLabel(projectRoot, label)}
|
||||
onRemoveLabel={(label) =>
|
||||
projectRoot && removeLabel(projectRoot, label)
|
||||
}
|
||||
onCopySha={() => navigator.clipboard.writeText(snap.id)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -238,13 +247,19 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
|
|||
placeholder="e.g. Draft v1"
|
||||
value={labelValue}
|
||||
onChange={(e) => setLabelValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleAddLabel(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddLabel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleAddLabel} disabled={!labelValue.trim()}>Add</Button>
|
||||
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddLabel} disabled={!labelValue.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -289,13 +304,13 @@ function SnapshotRow({
|
|||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-xs leading-tight font-medium",
|
||||
"rounded px-1.5 py-0.5 font-medium text-xs leading-tight",
|
||||
snapshotTypeBadgeColor(snapshot.message),
|
||||
)}
|
||||
>
|
||||
{snapshotTypeLabel(snapshot.message)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(snapshot.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -306,14 +321,17 @@ function SnapshotRow({
|
|||
{snapshot.labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="inline-flex items-center gap-0.5 rounded bg-amber-500/15 px-1.5 py-0.5 text-xs text-amber-600 dark:text-amber-400"
|
||||
className="inline-flex items-center gap-0.5 rounded bg-amber-500/15 px-1.5 py-0.5 text-amber-600 text-xs dark:text-amber-400"
|
||||
>
|
||||
<TagIcon className="size-2" />
|
||||
{label}
|
||||
<button
|
||||
aria-label={`Remove label ${label}`}
|
||||
className="ml-0.5 rounded-sm opacity-0 hover:text-destructive group-hover:opacity-100"
|
||||
onClick={(e) => { e.stopPropagation(); onRemoveLabel(label); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveLabel(label);
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2" />
|
||||
</button>
|
||||
|
|
@ -324,8 +342,10 @@ function SnapshotRow({
|
|||
|
||||
{/* Changed files summary */}
|
||||
{hasFiles && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground truncate">
|
||||
{snapshot.changed_files.map((f) => f.split("/").pop()).join(", ")}
|
||||
<div className="mt-0.5 truncate text-muted-foreground text-xs">
|
||||
{snapshot.changed_files
|
||||
.map((f) => f.split("/").pop())
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -349,4 +369,3 @@ function SnapshotRow({
|
|||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,21 +57,27 @@ export const MupdfPage = memo(function MupdfPage({
|
|||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpi = scale * 72 * dpr;
|
||||
|
||||
client.drawPage(docId, pageIndex, dpi).then(async (imageData) => {
|
||||
if (gen !== renderGenRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const bitmap = await createImageBitmap(imageData);
|
||||
if (gen !== renderGenRef.current) { bitmap.close(); return; }
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
}).catch((err) => {
|
||||
if (gen !== renderGenRef.current) return;
|
||||
log.error(`Render error page ${pageIndex}`, { error: String(err) });
|
||||
});
|
||||
client
|
||||
.drawPage(docId, pageIndex, dpi)
|
||||
.then(async (imageData) => {
|
||||
if (gen !== renderGenRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const bitmap = await createImageBitmap(imageData);
|
||||
if (gen !== renderGenRef.current) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (gen !== renderGenRef.current) return;
|
||||
log.error(`Render error page ${pageIndex}`, { error: String(err) });
|
||||
});
|
||||
}, [docId, pageIndex, scale, isVisible]);
|
||||
|
||||
// Initial render and re-render on dependency changes
|
||||
|
|
@ -83,15 +89,21 @@ export const MupdfPage = memo(function MupdfPage({
|
|||
const client = getMupdfClient();
|
||||
const gen = renderGenRef.current;
|
||||
|
||||
client.getPageText(docId, pageIndex).then((data) => {
|
||||
if (gen !== renderGenRef.current) return;
|
||||
setTextData(data);
|
||||
}).catch(() => {});
|
||||
client
|
||||
.getPageText(docId, pageIndex)
|
||||
.then((data) => {
|
||||
if (gen !== renderGenRef.current) return;
|
||||
setTextData(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
client.getPageLinks(docId, pageIndex).then((data) => {
|
||||
if (gen !== renderGenRef.current) return;
|
||||
setLinks(data);
|
||||
}).catch(() => {});
|
||||
client
|
||||
.getPageLinks(docId, pageIndex)
|
||||
.then((data) => {
|
||||
if (gen !== renderGenRef.current) return;
|
||||
setLinks(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [docId, pageIndex, scale, isVisible, renderPage]);
|
||||
|
||||
// Re-render canvas when returning from background if content was lost
|
||||
|
|
@ -100,13 +112,19 @@ export const MupdfPage = memo(function MupdfPage({
|
|||
const canvas = canvasRef.current;
|
||||
if (!canvas || !isVisible || docId <= 0) return;
|
||||
if (isCanvasBlank(canvas)) {
|
||||
log.warn(`Canvas blank after visibility restore, re-rendering page ${pageIndex}`);
|
||||
log.warn(
|
||||
`Canvas blank after visibility restore, re-rendering page ${pageIndex}`,
|
||||
);
|
||||
renderPage();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(APP_VISIBILITY_RESTORED, handleVisibilityRestored);
|
||||
return () => window.removeEventListener(APP_VISIBILITY_RESTORED, handleVisibilityRestored);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
APP_VISIBILITY_RESTORED,
|
||||
handleVisibilityRestored,
|
||||
);
|
||||
}, [docId, pageIndex, scale, isVisible, renderPage]);
|
||||
|
||||
return (
|
||||
|
|
@ -128,21 +146,22 @@ export const MupdfPage = memo(function MupdfPage({
|
|||
preserveAspectRatio="none"
|
||||
style={{ width: cssW, height: cssH }}
|
||||
>
|
||||
{textData.blocks.map((block, bi) =>
|
||||
block.type === "text" &&
|
||||
block.lines.map((line, li) => (
|
||||
<text
|
||||
key={`${bi}-${li}`}
|
||||
x={line.bbox.x}
|
||||
y={line.y}
|
||||
fontSize={line.font.size}
|
||||
fontFamily={line.font.family || line.font.name || "serif"}
|
||||
textLength={line.bbox.w > 0 ? line.bbox.w : undefined}
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
>
|
||||
{line.text}
|
||||
</text>
|
||||
)),
|
||||
{textData.blocks.map(
|
||||
(block, bi) =>
|
||||
block.type === "text" &&
|
||||
block.lines.map((line, li) => (
|
||||
<text
|
||||
key={`${bi}-${li}`}
|
||||
x={line.bbox.x}
|
||||
y={line.y}
|
||||
fontSize={line.font.size}
|
||||
fontFamily={line.font.family || line.font.name || "serif"}
|
||||
textLength={line.bbox.w > 0 ? line.bbox.w : undefined}
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
>
|
||||
{line.text}
|
||||
</text>
|
||||
)),
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
|
|
@ -156,12 +175,14 @@ export const MupdfPage = memo(function MupdfPage({
|
|||
href={link.href}
|
||||
data-external={link.isExternal ? "true" : undefined}
|
||||
style={{
|
||||
left: (link.x / pageWidth) * 100 + "%",
|
||||
top: (link.y / pageHeight) * 100 + "%",
|
||||
width: (link.w / pageWidth) * 100 + "%",
|
||||
height: (link.h / pageHeight) * 100 + "%",
|
||||
left: `${(link.x / pageWidth) * 100}%`,
|
||||
top: `${(link.y / pageHeight) * 100}%`,
|
||||
width: `${(link.w / pageWidth) * 100}%`,
|
||||
height: `${(link.h / pageHeight) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<span className="sr-only">Link</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ import {
|
|||
} from "lucide-react";
|
||||
import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs";
|
||||
import { join } from "@tauri-apps/api/path";
|
||||
import { useDocumentStore, getPdfBytes, getCurrentPdfBytes, hasPdfData } from "@/stores/document-store";
|
||||
import {
|
||||
useDocumentStore,
|
||||
getPdfBytes,
|
||||
getCurrentPdfBytes,
|
||||
hasPdfData,
|
||||
} from "@/stores/document-store";
|
||||
import { useHistoryStore } from "@/stores/history-store";
|
||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -28,13 +33,29 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { HistoryPanel } from "@/components/workspace/history-panel";
|
||||
import { compileLatex, synctexEdit, resolveCompileTarget, formatCompileError } from "@/lib/latex-compiler";
|
||||
import {
|
||||
compileLatex,
|
||||
synctexEdit,
|
||||
resolveCompileTarget,
|
||||
formatCompileError,
|
||||
} from "@/lib/latex-compiler";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { SelectionToolbar, type ToolbarAction } from "@/components/workspace/editor/selection-toolbar";
|
||||
import {
|
||||
SelectionToolbar,
|
||||
type ToolbarAction,
|
||||
} from "@/components/workspace/editor/selection-toolbar";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { PdfViewer, type PdfTextSelection, type CaptureResult } from "./pdf-viewer";
|
||||
import {
|
||||
PdfViewer,
|
||||
type PdfTextSelection,
|
||||
type CaptureResult,
|
||||
} from "./pdf-viewer";
|
||||
import { resolveTexRoot } from "@/stores/document-store";
|
||||
import { createLogger } from "@/lib/debug/logger";
|
||||
|
||||
|
|
@ -95,8 +116,14 @@ export function PdfPreview() {
|
|||
const [scale, setScale] = useState<number>(1.0);
|
||||
const [captureMode, setCaptureMode] = useState(false);
|
||||
const [fitMode, setFitMode] = useState<FitMode>(null);
|
||||
const [containerSize, setContainerSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [firstPageSize, setFirstPageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [containerSize, setContainerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [firstPageSize, setFirstPageSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const hasInitialCompile = useRef(false);
|
||||
const initialized = useDocumentStore((s) => s.initialized);
|
||||
|
||||
|
|
@ -135,7 +162,9 @@ export function PdfPreview() {
|
|||
}, [currentRootFileId, pdfData]);
|
||||
|
||||
// PDF text selection toolbar
|
||||
const [pdfSelection, setPdfSelection] = useState<PdfTextSelection | null>(null);
|
||||
const [pdfSelection, setPdfSelection] = useState<PdfTextSelection | null>(
|
||||
null,
|
||||
);
|
||||
const previewContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTextClick = useCallback(
|
||||
|
|
@ -163,7 +192,8 @@ export function PdfPreview() {
|
|||
const result = await synctexEdit(projectRoot, page, x, y);
|
||||
if (!result) return;
|
||||
|
||||
const normalize = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
const normalize = (p: string) =>
|
||||
p.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
const normalizedTarget = normalize(result.file);
|
||||
const targetFile = files.find(
|
||||
(f) => normalize(f.relativePath) === normalizedTarget,
|
||||
|
|
@ -184,7 +214,10 @@ export function PdfPreview() {
|
|||
offset += fileLines[i].length + 1;
|
||||
}
|
||||
if (result.column > 0) {
|
||||
offset += Math.min(result.column, fileLines[targetLine - 1]?.length ?? 0);
|
||||
offset += Math.min(
|
||||
result.column,
|
||||
fileLines[targetLine - 1]?.length ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (needsSwitch) {
|
||||
|
|
@ -212,13 +245,20 @@ export function PdfPreview() {
|
|||
useEffect(() => {
|
||||
if (!pdfSelection || !projectRoot) return;
|
||||
let cancelled = false;
|
||||
synctexEdit(projectRoot, pdfSelection.pageNumber, pdfSelection.pdfX, pdfSelection.pdfY)
|
||||
synctexEdit(
|
||||
projectRoot,
|
||||
pdfSelection.pageNumber,
|
||||
pdfSelection.pdfX,
|
||||
pdfSelection.pdfY,
|
||||
)
|
||||
.then((result) => {
|
||||
if (cancelled || !result) return;
|
||||
setResolvedSource(result);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pdfSelection, projectRoot]);
|
||||
|
||||
const pdfContextLabel = resolvedSource
|
||||
|
|
@ -242,13 +282,19 @@ export function PdfPreview() {
|
|||
|
||||
const fileContent = targetFile.content ?? "";
|
||||
const fileLines = fileContent.split("\n");
|
||||
const targetLine = Math.max(1, Math.min(resolvedSource.line, fileLines.length));
|
||||
const targetLine = Math.max(
|
||||
1,
|
||||
Math.min(resolvedSource.line, fileLines.length),
|
||||
);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < targetLine - 1; i++) {
|
||||
offset += fileLines[i].length + 1;
|
||||
}
|
||||
if (resolvedSource.column > 0) {
|
||||
offset += Math.min(resolvedSource.column, fileLines[targetLine - 1]?.length ?? 0);
|
||||
offset += Math.min(
|
||||
resolvedSource.column,
|
||||
fileLines[targetLine - 1]?.length ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (needsSwitch) {
|
||||
|
|
@ -258,14 +304,17 @@ export function PdfPreview() {
|
|||
}
|
||||
}, [resolvedSource, files, setActiveFile, requestJumpToPosition]);
|
||||
|
||||
const buildPdfContext = useCallback((text: string) => {
|
||||
const locationNote = resolvedSource
|
||||
? `near ${resolvedSource.file}:${resolvedSource.line}`
|
||||
: pdfSelection
|
||||
? `PDF page ${pdfSelection.pageNumber}`
|
||||
: "PDF";
|
||||
return `[Selected from PDF output, approximate source location: ${locationNote}]\n${text}`;
|
||||
}, [resolvedSource, pdfSelection]);
|
||||
const buildPdfContext = useCallback(
|
||||
(text: string) => {
|
||||
const locationNote = resolvedSource
|
||||
? `near ${resolvedSource.file}:${resolvedSource.line}`
|
||||
: pdfSelection
|
||||
? `PDF page ${pdfSelection.pageNumber}`
|
||||
: "PDF";
|
||||
return `[Selected from PDF output, approximate source location: ${locationNote}]\n${text}`;
|
||||
},
|
||||
[resolvedSource, pdfSelection],
|
||||
);
|
||||
|
||||
const handlePdfToolbarSendPrompt = useCallback(
|
||||
(prompt: string) => {
|
||||
|
|
@ -283,10 +332,22 @@ export function PdfPreview() {
|
|||
[pdfSelection, pdfContextLabel, resolvedSource, buildPdfContext],
|
||||
);
|
||||
|
||||
const pdfToolbarActions: ToolbarAction[] = useMemo(() => [
|
||||
{ id: "proofread", label: "Proofread", icon: <SpellCheckIcon className="size-4" /> },
|
||||
{ id: "navigate", label: "Navigate to source", icon: <FileTextIcon className="size-4" />, hint: "dbl-click" },
|
||||
], []);
|
||||
const pdfToolbarActions: ToolbarAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "proofread",
|
||||
label: "Proofread",
|
||||
icon: <SpellCheckIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: "navigate",
|
||||
label: "Navigate to source",
|
||||
icon: <FileTextIcon className="size-4" />,
|
||||
hint: "dbl-click",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePdfToolbarAction = useCallback(
|
||||
(actionId: string) => {
|
||||
|
|
@ -296,16 +357,24 @@ export function PdfPreview() {
|
|||
setPdfSelection(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
if (actionId === "proofread") {
|
||||
useClaudeChatStore.getState().sendPrompt("Proofread and fix any errors in this text", {
|
||||
label,
|
||||
filePath: resolvedSource?.file ?? "document.pdf",
|
||||
selectedText: buildPdfContext(sel.text),
|
||||
});
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
.sendPrompt("Proofread and fix any errors in this text", {
|
||||
label,
|
||||
filePath: resolvedSource?.file ?? "document.pdf",
|
||||
selectedText: buildPdfContext(sel.text),
|
||||
});
|
||||
} else if (actionId === "navigate") {
|
||||
navigateToSource();
|
||||
}
|
||||
},
|
||||
[pdfSelection, pdfContextLabel, resolvedSource, navigateToSource, buildPdfContext],
|
||||
[
|
||||
pdfSelection,
|
||||
pdfContextLabel,
|
||||
resolvedSource,
|
||||
navigateToSource,
|
||||
buildPdfContext,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePdfToolbarDismiss = useCallback(() => {
|
||||
|
|
@ -317,10 +386,13 @@ export function PdfPreview() {
|
|||
if (!pdfSelection || !previewContainerRef.current) return null;
|
||||
const containerRect = previewContainerRef.current.getBoundingClientRect();
|
||||
const relTop = pdfSelection.position.top - containerRect.top + 4;
|
||||
const relLeft = Math.max(8, Math.min(
|
||||
pdfSelection.position.left - containerRect.left,
|
||||
containerRect.width - 272,
|
||||
));
|
||||
const relLeft = Math.max(
|
||||
8,
|
||||
Math.min(
|
||||
pdfSelection.position.left - containerRect.left,
|
||||
containerRect.width - 272,
|
||||
),
|
||||
);
|
||||
return { top: relTop, left: relLeft };
|
||||
})();
|
||||
|
||||
|
|
@ -338,7 +410,9 @@ export function PdfPreview() {
|
|||
const { files: allFiles, activeFileId } = useDocumentStore.getState();
|
||||
const resolved = resolveCompileTarget(activeFileId, allFiles);
|
||||
if (!resolved) {
|
||||
setCompileError("No .tex file found in this project. Create a main.tex file to compile.");
|
||||
setCompileError(
|
||||
"No .tex file found in this project. Create a main.tex file to compile.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { rootId, targetPath } = resolved;
|
||||
|
|
@ -351,7 +425,19 @@ export function PdfPreview() {
|
|||
}
|
||||
};
|
||||
compile();
|
||||
}, [initialized, projectRoot, pdfData, isCompiling, compileError, setIsCompiling, setPdfData, setCompileError, saveAllFiles, files, activeFile]);
|
||||
}, [
|
||||
initialized,
|
||||
projectRoot,
|
||||
pdfData,
|
||||
isCompiling,
|
||||
compileError,
|
||||
setIsCompiling,
|
||||
setPdfData,
|
||||
setCompileError,
|
||||
saveAllFiles,
|
||||
files,
|
||||
activeFile,
|
||||
]);
|
||||
|
||||
// Recompute scale when fit mode is active and container/page size changes
|
||||
useEffect(() => {
|
||||
|
|
@ -366,13 +452,21 @@ export function PdfPreview() {
|
|||
}
|
||||
}, [fitMode, containerSize, firstPageSize]);
|
||||
|
||||
const zoomIn = () => { setFitMode(null); setScale((s) => Math.min(4, s + 0.1)); };
|
||||
const zoomOut = () => { setFitMode(null); setScale((s) => Math.max(0.25, s - 0.1)); };
|
||||
const zoomIn = () => {
|
||||
setFitMode(null);
|
||||
setScale((s) => Math.min(4, s + 0.1));
|
||||
};
|
||||
const zoomOut = () => {
|
||||
setFitMode(null);
|
||||
setScale((s) => Math.max(0.25, s - 0.1));
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const currentPdf = getCurrentPdfBytes();
|
||||
if (!currentPdf) return;
|
||||
const mainFile = files.find((f) => f.name === "main.tex" || f.name === "document.tex");
|
||||
const mainFile = files.find(
|
||||
(f) => f.name === "main.tex" || f.name === "document.tex",
|
||||
);
|
||||
const defaultName = mainFile
|
||||
? mainFile.name.replace(/\.tex$/, ".pdf")
|
||||
: "document.pdf";
|
||||
|
|
@ -385,23 +479,29 @@ export function PdfPreview() {
|
|||
await writeFile(filePath, new Uint8Array(currentPdf));
|
||||
};
|
||||
|
||||
const handleCurrentPageChange = useCallback((page: number) => {
|
||||
setCurrentPage((prev) => {
|
||||
if (prev === page) return prev;
|
||||
if (!isEditingPage) setPageInputValue(String(page));
|
||||
return page;
|
||||
});
|
||||
}, [isEditingPage]);
|
||||
const handleCurrentPageChange = useCallback(
|
||||
(page: number) => {
|
||||
setCurrentPage((prev) => {
|
||||
if (prev === page) return prev;
|
||||
if (!isEditingPage) setPageInputValue(String(page));
|
||||
return page;
|
||||
});
|
||||
},
|
||||
[isEditingPage],
|
||||
);
|
||||
|
||||
const goToPage = useCallback((page: number) => {
|
||||
const clamped = Math.max(1, Math.min(numPages, page));
|
||||
scrollToPageRef.current?.(clamped);
|
||||
}, [numPages]);
|
||||
const goToPage = useCallback(
|
||||
(page: number) => {
|
||||
const clamped = Math.max(1, Math.min(numPages, page));
|
||||
scrollToPageRef.current?.(clamped);
|
||||
},
|
||||
[numPages],
|
||||
);
|
||||
|
||||
const handlePageInputCommit = useCallback(() => {
|
||||
setIsEditingPage(false);
|
||||
const parsed = parseInt(pageInputValue, 10);
|
||||
if (!isNaN(parsed) && parsed >= 1 && parsed <= numPages) {
|
||||
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= numPages) {
|
||||
goToPage(parsed);
|
||||
} else {
|
||||
setPageInputValue(String(currentPage));
|
||||
|
|
@ -409,7 +509,10 @@ export function PdfPreview() {
|
|||
}, [pageInputValue, numPages, currentPage, goToPage]);
|
||||
|
||||
const handleLoadSuccess = (pages: number) => setNumPages(pages);
|
||||
const handleScaleChange = (newScale: number) => { setFitMode(null); setScale(newScale); };
|
||||
const handleScaleChange = (newScale: number) => {
|
||||
setFitMode(null);
|
||||
setScale(newScale);
|
||||
};
|
||||
|
||||
const handleCompile = async () => {
|
||||
// Read all guard values from the store to avoid stale closures
|
||||
|
|
@ -426,13 +529,20 @@ export function PdfPreview() {
|
|||
if (!activeEntry || activeEntry.type !== "tex") return;
|
||||
const resolved = resolveCompileTarget(activeFileId, allFiles);
|
||||
if (!resolved) {
|
||||
setCompileError("No .tex file found in this project. Create a main.tex file to compile.");
|
||||
setCompileError(
|
||||
"No .tex file found in this project. Create a main.tex file to compile.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { rootId, targetPath: targetFile } = resolved;
|
||||
// Skip recompile if no edits since last successful compile of this root
|
||||
const lastGen = state.lastCompiledGenerations.get(rootId);
|
||||
if (hasPdfData() && lastGen !== undefined && state.contentGeneration === lastGen) return;
|
||||
if (
|
||||
hasPdfData() &&
|
||||
lastGen !== undefined &&
|
||||
state.contentGeneration === lastGen
|
||||
)
|
||||
return;
|
||||
useHistoryStore.getState().stopReview();
|
||||
setIsCompiling(true);
|
||||
state.setPendingRecompile(false);
|
||||
|
|
@ -492,23 +602,28 @@ export function PdfPreview() {
|
|||
if (pdfData) setCaptureMode((prev) => !prev);
|
||||
};
|
||||
window.addEventListener("toggle-capture-mode", handleToggleCapture);
|
||||
return () => window.removeEventListener("toggle-capture-mode", handleToggleCapture);
|
||||
return () =>
|
||||
window.removeEventListener("toggle-capture-mode", handleToggleCapture);
|
||||
}, [pdfData]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (compileError) {
|
||||
const errors = [...new Set(
|
||||
compileError
|
||||
.split(/\s*!\s*/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0 && s !== "Compilation failed"),
|
||||
)];
|
||||
const errors = [
|
||||
...new Set(
|
||||
compileError
|
||||
.split(/\s*!\s*/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0 && s !== "Compilation failed"),
|
||||
),
|
||||
];
|
||||
|
||||
const handleFixWithChat = () => {
|
||||
const errorList = errors.map((e) => `- ${e}`).join("\n");
|
||||
useClaudeChatStore.getState().sendPrompt(
|
||||
`[Compilation errors]\n${errorList}\n\nFix these LaTeX compilation errors.`,
|
||||
);
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
.sendPrompt(
|
||||
`[Compilation errors]\n${errorList}\n\nFix these LaTeX compilation errors.`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -517,16 +632,16 @@ export function PdfPreview() {
|
|||
<div className="mb-4 flex items-center gap-2 text-destructive">
|
||||
<AlertCircleIcon className="size-5" />
|
||||
<h2 className="font-semibold text-base">Compilation Failed</h2>
|
||||
<span className="ml-auto rounded-full bg-destructive/15 px-2 py-0.5 text-xs font-medium">
|
||||
<span className="ml-auto rounded-full bg-destructive/15 px-2 py-0.5 font-medium text-xs">
|
||||
{errors.length} {errors.length === 1 ? "error" : "errors"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-destructive/20 bg-background">
|
||||
<div className="max-h-60 overflow-y-auto divide-y divide-border">
|
||||
<div className="max-h-60 divide-y divide-border overflow-y-auto">
|
||||
{errors.map((error, i) => (
|
||||
<div key={i} className="flex items-start gap-2.5 px-3 py-2.5">
|
||||
<AlertCircleIcon className="mt-0.5 size-3.5 shrink-0 text-destructive/70" />
|
||||
<span className="text-sm text-foreground">{error}</span>
|
||||
<span className="text-foreground text-sm">{error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -534,14 +649,14 @@ export function PdfPreview() {
|
|||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleFixWithChat}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 font-medium text-primary-foreground text-xs shadow-sm transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<MousePointerClickIcon className="size-3.5" />
|
||||
Fix with Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCompile}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 font-medium text-foreground text-xs transition-colors hover:bg-muted"
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
Retry
|
||||
|
|
@ -555,10 +670,19 @@ export function PdfPreview() {
|
|||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 p-8">
|
||||
<FileTextIcon className="mb-4 size-16 text-muted-foreground/50" />
|
||||
<h2 className="mb-2 font-medium text-lg text-muted-foreground">PDF Preview</h2>
|
||||
<p className="mb-4 text-center text-muted-foreground text-sm">Press ⌘+Enter to compile your document</p>
|
||||
<h2 className="mb-2 font-medium text-lg text-muted-foreground">
|
||||
PDF Preview
|
||||
</h2>
|
||||
<p className="mb-4 text-center text-muted-foreground text-sm">
|
||||
Press ⌘+Enter to compile your document
|
||||
</p>
|
||||
{isTexActive && (
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCompile}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleCompile}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
Compile
|
||||
</Button>
|
||||
|
|
@ -570,8 +694,12 @@ export function PdfPreview() {
|
|||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 p-8">
|
||||
<AlertCircleIcon className="mb-4 size-12 text-destructive" />
|
||||
<h2 className="mb-2 font-medium text-destructive text-lg">PDF Load Error</h2>
|
||||
<p className="max-w-md text-center text-muted-foreground text-sm">{pdfError}</p>
|
||||
<h2 className="mb-2 font-medium text-destructive text-lg">
|
||||
PDF Load Error
|
||||
</h2>
|
||||
<p className="max-w-md text-center text-muted-foreground text-sm">
|
||||
{pdfError}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -591,8 +719,15 @@ export function PdfPreview() {
|
|||
fallback={
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-muted/30 p-8">
|
||||
<AlertCircleIcon className="size-10 text-destructive" />
|
||||
<p className="text-sm text-muted-foreground">PDF viewer crashed. Try recompiling.</p>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCompile}>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
PDF viewer crashed. Try recompiling.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleCompile}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
Recompile
|
||||
</Button>
|
||||
|
|
@ -603,7 +738,7 @@ export function PdfPreview() {
|
|||
className={
|
||||
isActive
|
||||
? "absolute inset-0 flex flex-col"
|
||||
: "invisible pointer-events-none absolute inset-0 flex flex-col"
|
||||
: "pointer-events-none invisible absolute inset-0 flex flex-col"
|
||||
}
|
||||
>
|
||||
<PdfViewer
|
||||
|
|
@ -617,13 +752,25 @@ export function PdfPreview() {
|
|||
onTextClick={isActive ? handleTextClick : undefined}
|
||||
onSynctexClick={isActive ? handleSynctexClick : undefined}
|
||||
onTextSelect={isActive ? handleTextSelect : undefined}
|
||||
onFirstPageSize={isActive ? (w, h) => setFirstPageSize({ width: w, height: h }) : undefined}
|
||||
onContainerResize={isActive ? (w, h) => setContainerSize({ width: w, height: h }) : undefined}
|
||||
onCurrentPageChange={isActive ? handleCurrentPageChange : undefined}
|
||||
onFirstPageSize={
|
||||
isActive
|
||||
? (w, h) => setFirstPageSize({ width: w, height: h })
|
||||
: undefined
|
||||
}
|
||||
onContainerResize={
|
||||
isActive
|
||||
? (w, h) => setContainerSize({ width: w, height: h })
|
||||
: undefined
|
||||
}
|
||||
onCurrentPageChange={
|
||||
isActive ? handleCurrentPageChange : undefined
|
||||
}
|
||||
scrollToPageRef={isActive ? scrollToPageRef : undefined}
|
||||
captureMode={isActive ? captureMode : false}
|
||||
onCapture={isActive ? handleCapture : undefined}
|
||||
onCancelCapture={isActive ? () => setCaptureMode(false) : undefined}
|
||||
onCancelCapture={
|
||||
isActive ? () => setCaptureMode(false) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
|
|
@ -634,29 +781,47 @@ export function PdfPreview() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div ref={previewContainerRef} className="@container/pv relative flex h-full flex-col bg-muted/50">
|
||||
<div className="flex shrink-0 items-center border-border border-b bg-background px-2 pt-[var(--titlebar-height)] h-[calc(40px+var(--titlebar-height))]">
|
||||
<div
|
||||
ref={previewContainerRef}
|
||||
className="@container/pv relative flex h-full flex-col bg-muted/50"
|
||||
>
|
||||
<div className="flex h-[calc(40px+var(--titlebar-height))] shrink-0 items-center border-border border-b bg-background px-2 pt-[var(--titlebar-height)]">
|
||||
<div className="flex items-center gap-1">
|
||||
{isSaving && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-xs font-medium">Saving...</span>
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
Saving...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isSaving && isCompiling && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-xs font-medium">Compiling...</span>
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
Compiling...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isSaving && !isCompiling && !compileError && isTexActive && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={handleCompile}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||
onClick={handleCompile}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
{pdfData ? "Recompile" : "Compile"}
|
||||
</Button>
|
||||
)}
|
||||
{!isSaving && !isCompiling && compileError && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs text-destructive hover:text-destructive" onClick={handleCompile} disabled={!isTexActive}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2.5 text-destructive text-xs hover:text-destructive"
|
||||
onClick={handleCompile}
|
||||
disabled={!isTexActive}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
Retry
|
||||
</Button>
|
||||
|
|
@ -666,15 +831,21 @@ export function PdfPreview() {
|
|||
<div className="flex shrink-0 items-center gap-1">
|
||||
{pdfData && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1} title="Page Up">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
title="Page Up"
|
||||
>
|
||||
<ChevronUpIcon className="size-3.5" />
|
||||
</Button>
|
||||
{isEditingPage ? (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="h-6 w-8 shrink-0 rounded border border-border bg-background text-center text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
className="h-6 w-8 shrink-0 rounded border border-border bg-background text-center text-foreground text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={pageInputValue}
|
||||
onChange={(e) => setPageInputValue(e.target.value)}
|
||||
onBlur={handlePageInputCommit}
|
||||
|
|
@ -688,20 +859,48 @@ export function PdfPreview() {
|
|||
/>
|
||||
) : (
|
||||
<button
|
||||
className="flex h-6 min-w-[2rem] shrink-0 items-center justify-center rounded px-1 text-xs text-muted-foreground tabular-nums hover:bg-muted"
|
||||
onClick={() => { setIsEditingPage(true); setPageInputValue(String(currentPage)); }}
|
||||
className="flex h-6 min-w-[2rem] shrink-0 items-center justify-center rounded px-1 text-muted-foreground text-xs tabular-nums hover:bg-muted"
|
||||
onClick={() => {
|
||||
setIsEditingPage(true);
|
||||
setPageInputValue(String(currentPage));
|
||||
}}
|
||||
title="Click to jump to page"
|
||||
>
|
||||
{currentPage}
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 whitespace-nowrap text-muted-foreground text-xs">/ {numPages}</span>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={() => goToPage(currentPage + 1)} disabled={currentPage >= numPages} title="Page Down">
|
||||
<span className="shrink-0 whitespace-nowrap text-muted-foreground text-xs">
|
||||
/ {numPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= numPages}
|
||||
title="Page Down"
|
||||
>
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</Button>
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={zoomOut} disabled={scale <= 0.25}><MinusIcon className="size-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={zoomIn} disabled={scale >= 4}><PlusIcon className="size-3.5" /></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={zoomOut}
|
||||
disabled={scale <= 0.25}
|
||||
>
|
||||
<MinusIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={zoomIn}
|
||||
disabled={scale >= 4}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Select
|
||||
value={fitMode ?? scale.toString()}
|
||||
onValueChange={(v) => {
|
||||
|
|
@ -715,14 +914,22 @@ export function PdfPreview() {
|
|||
>
|
||||
<SelectTrigger size="sm" className="h-7! w-auto text-xs">
|
||||
<SelectValue>
|
||||
{fitMode === "fit-width" ? "Fit width" : fitMode === "fit-height" ? "Fit height" : `${Math.round(scale * 100)}%`}
|
||||
{fitMode === "fit-width"
|
||||
? "Fit width"
|
||||
: fitMode === "fit-height"
|
||||
? "Fit height"
|
||||
: `${Math.round(scale * 100)}%`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectItem value="fit-width">Fit to width</SelectItem>
|
||||
<SelectItem value="fit-height">Fit to height</SelectItem>
|
||||
<SelectSeparator />
|
||||
{ZOOM_OPTIONS.map((opt) => (<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>))}
|
||||
{ZOOM_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
|
|
@ -739,20 +946,31 @@ export function PdfPreview() {
|
|||
title={`Capture & Ask (${navigator.userAgent.includes("Mac") ? "⌘X" : "Ctrl+X"})`}
|
||||
>
|
||||
<CrosshairIcon className="size-3.5 shrink-0" />
|
||||
<span className="hidden @[36rem]/pv:inline">Capture & Ask</span>
|
||||
<kbd className="pointer-events-none ml-0.5 hidden rounded border border-background/30 bg-background/20 px-1 py-0.5 text-[10px] font-medium leading-none text-background @[36rem]/pv:inline">
|
||||
<span className="@[36rem]/pv:inline hidden">Capture & Ask</span>
|
||||
<kbd className="pointer-events-none ml-0.5 @[36rem]/pv:inline hidden rounded border border-background/30 bg-background/20 px-1 py-0.5 font-medium text-[10px] text-background leading-none">
|
||||
{navigator.userAgent.includes("Mac") ? "⌘X" : "Ctrl+X"}
|
||||
</kbd>
|
||||
</Button>
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={handleExport} title="Export PDF">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={handleExport}
|
||||
title="Export PDF"
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-7" title="History">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
title="History"
|
||||
>
|
||||
<HistoryIcon className="size-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -779,14 +997,14 @@ export function PdfPreview() {
|
|||
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-sm">
|
||||
<CrosshairIcon className="size-3.5 text-primary" />
|
||||
<span className="text-xs text-foreground">
|
||||
<span className="text-foreground text-xs">
|
||||
Drag to select a region
|
||||
</span>
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-medium text-[10px] text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
<span className="text-[10px] text-muted-foreground">or</span>
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-medium text-[10px] text-muted-foreground">
|
||||
{navigator.userAgent.includes("Mac") ? "⌘" : "Ctrl+"}X
|
||||
</kbd>
|
||||
<span className="text-[10px] text-muted-foreground">to cancel</span>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { useCallback, useRef, useEffect, useState } from "react";
|
|||
import { LoaderIcon } from "lucide-react";
|
||||
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
||||
import { ask } from "@tauri-apps/plugin-dialog";
|
||||
import { getCachedDocument, getOrOpenDocument } from "@/lib/mupdf/pdf-doc-cache";
|
||||
import {
|
||||
getCachedDocument,
|
||||
getOrOpenDocument,
|
||||
} from "@/lib/mupdf/pdf-doc-cache";
|
||||
import { MupdfPage } from "./mupdf-page";
|
||||
import { createLogger } from "@/lib/debug/logger";
|
||||
import { APP_VISIBILITY_RESTORED } from "@/lib/debug/log-store";
|
||||
|
|
@ -99,7 +102,8 @@ export function PdfViewer({
|
|||
useEffect(() => {
|
||||
const handleRestore = () => setFocusGen((g) => g + 1);
|
||||
window.addEventListener(APP_VISIBILITY_RESTORED, handleRestore);
|
||||
return () => window.removeEventListener(APP_VISIBILITY_RESTORED, handleRestore);
|
||||
return () =>
|
||||
window.removeEventListener(APP_VISIBILITY_RESTORED, handleRestore);
|
||||
}, []);
|
||||
|
||||
// Keep-alive scroll save/restore
|
||||
|
|
@ -127,7 +131,9 @@ export function PdfViewer({
|
|||
}, [isActive]);
|
||||
|
||||
// Capture drag state
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null);
|
||||
const [dragPageNum, setDragPageNum] = useState(0);
|
||||
|
||||
|
|
@ -181,8 +187,17 @@ export function PdfViewer({
|
|||
data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
|
||||
|
||||
// Validate PDF header — must start with %PDF-
|
||||
if (pdfData.length < 5 || pdfData[0] !== 0x25 || pdfData[1] !== 0x50 || pdfData[2] !== 0x44 || pdfData[3] !== 0x46) {
|
||||
log.error("Invalid PDF data: missing %PDF- header", { length: pdfData.length, firstBytes: Array.from(pdfData.slice(0, 16)) });
|
||||
if (
|
||||
pdfData.length < 5 ||
|
||||
pdfData[0] !== 0x25 ||
|
||||
pdfData[1] !== 0x50 ||
|
||||
pdfData[2] !== 0x44 ||
|
||||
pdfData[3] !== 0x46
|
||||
) {
|
||||
log.error("Invalid PDF data: missing %PDF- header", {
|
||||
length: pdfData.length,
|
||||
firstBytes: Array.from(pdfData.slice(0, 16)),
|
||||
});
|
||||
setLoading(false);
|
||||
onError?.("Invalid PDF data received. Try recompiling the document.");
|
||||
return;
|
||||
|
|
@ -241,7 +256,10 @@ export function PdfViewer({
|
|||
setLoading(false);
|
||||
|
||||
if (isFirstLoad.current && syncResult.pageSizes.length > 0) {
|
||||
onFirstPageSize?.(syncResult.pageSizes[0].width, syncResult.pageSizes[0].height);
|
||||
onFirstPageSize?.(
|
||||
syncResult.pageSizes[0].width,
|
||||
syncResult.pageSizes[0].height,
|
||||
);
|
||||
}
|
||||
isFirstLoad.current = false;
|
||||
onLoadSuccess?.(syncResult.pageSizes.length);
|
||||
|
|
@ -296,7 +314,10 @@ export function PdfViewer({
|
|||
const next = new Set(prev);
|
||||
for (const entry of entries) {
|
||||
const el = entry.target as HTMLElement;
|
||||
const pageNum = parseInt(el.getAttribute("data-page-number") || "0", 10);
|
||||
const pageNum = parseInt(
|
||||
el.getAttribute("data-page-number") || "0",
|
||||
10,
|
||||
);
|
||||
if (pageNum === 0) continue;
|
||||
if (entry.isIntersecting) {
|
||||
next.add(pageNum);
|
||||
|
|
@ -345,7 +366,10 @@ export function PdfViewer({
|
|||
const pageEl = target.closest(".mupdf-page") as HTMLElement | null;
|
||||
if (!pageEl) return;
|
||||
|
||||
const pageNum = parseInt(pageEl.getAttribute("data-page-number") || "0", 10);
|
||||
const pageNum = parseInt(
|
||||
pageEl.getAttribute("data-page-number") || "0",
|
||||
10,
|
||||
);
|
||||
if (pageNum === 0) return;
|
||||
|
||||
const rect = pageEl.getBoundingClientRect();
|
||||
|
|
@ -472,7 +496,9 @@ export function PdfViewer({
|
|||
const container = containerRef.current;
|
||||
if (container) scrollToPage(container, page);
|
||||
};
|
||||
return () => { if (scrollToPageRef) scrollToPageRef.current = null; };
|
||||
return () => {
|
||||
if (scrollToPageRef) scrollToPageRef.current = null;
|
||||
};
|
||||
}, [scrollToPageRef, pageSizes]);
|
||||
|
||||
// Dismiss selection toolbar on scroll
|
||||
|
|
@ -562,7 +588,11 @@ export function PdfViewer({
|
|||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:")) {
|
||||
if (
|
||||
href.startsWith("http://") ||
|
||||
href.startsWith("https://") ||
|
||||
href.startsWith("mailto:")
|
||||
) {
|
||||
ask(`Open in browser?\n${href}`, {
|
||||
title: "External Link",
|
||||
kind: "info",
|
||||
|
|
@ -602,7 +632,10 @@ export function PdfViewer({
|
|||
const target = e.target as HTMLElement;
|
||||
const pageEl = target.closest(".mupdf-page") as HTMLElement | null;
|
||||
if (!pageEl) return;
|
||||
const pageNum = parseInt(pageEl.getAttribute("data-page-number") || "0");
|
||||
const pageNum = parseInt(
|
||||
pageEl.getAttribute("data-page-number") || "0",
|
||||
10,
|
||||
);
|
||||
if (!pageNum) return;
|
||||
setDragPageNum(pageNum);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
|
|
@ -639,7 +672,9 @@ export function PdfViewer({
|
|||
const pageEl = containerRef.current?.querySelector(
|
||||
`.mupdf-page[data-page-number="${dragPageNum}"]`,
|
||||
) as HTMLElement | null;
|
||||
const sourceCanvas = pageEl?.querySelector("canvas") as HTMLCanvasElement | null;
|
||||
const sourceCanvas = pageEl?.querySelector(
|
||||
"canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (!pageEl || !sourceCanvas) {
|
||||
setDragStart(null);
|
||||
setDragEnd(null);
|
||||
|
|
@ -692,10 +727,7 @@ export function PdfViewer({
|
|||
(e: React.MouseEvent) => {
|
||||
if (!onTextClick) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "text" &&
|
||||
target.closest(".mupdf-text-layer")
|
||||
) {
|
||||
if (target.tagName === "text" && target.closest(".mupdf-text-layer")) {
|
||||
const text = target.textContent?.trim();
|
||||
if (text && text.length > 2) {
|
||||
onTextClick(text);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
FileCodeIcon,
|
||||
FileIcon,
|
||||
FileSpreadsheetIcon,
|
||||
GripVerticalIcon,
|
||||
AppWindowIcon,
|
||||
FlaskConicalIcon,
|
||||
TerminalIcon,
|
||||
|
|
@ -100,7 +99,11 @@ function parseTableOfContents(content: string): TocItem[] {
|
|||
const match = line.match(sectionRegex);
|
||||
if (match) {
|
||||
const [, type, title] = match;
|
||||
toc.push({ level: levelMap[type] ?? 2, title: title.trim(), line: index + 1 });
|
||||
toc.push({
|
||||
level: levelMap[type] ?? 2,
|
||||
title: title.trim(),
|
||||
line: index + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
return toc;
|
||||
|
|
@ -179,8 +182,10 @@ function buildFileTree(files: ProjectFile[], folders: string[]): TreeNode[] {
|
|||
|
||||
function getFileIcon(file: ProjectFile) {
|
||||
if (file.type === "image") return <ImageIcon className="size-4 shrink-0" />;
|
||||
if (file.type === "pdf") return <FileSpreadsheetIcon className="size-4 shrink-0" />;
|
||||
if (file.type === "style") return <FileCodeIcon className="size-4 shrink-0" />;
|
||||
if (file.type === "pdf")
|
||||
return <FileSpreadsheetIcon className="size-4 shrink-0" />;
|
||||
if (file.type === "style")
|
||||
return <FileCodeIcon className="size-4 shrink-0" />;
|
||||
if (file.type === "other") return <FileIcon className="size-4 shrink-0" />;
|
||||
return <FileTextIcon className="size-4 shrink-0" />;
|
||||
}
|
||||
|
|
@ -188,7 +193,9 @@ function getFileIcon(file: ProjectFile) {
|
|||
// ─── App Version (resolved once from Tauri) ───
|
||||
|
||||
let _appVersion = "";
|
||||
getVersion().then((v) => { _appVersion = v; });
|
||||
getVersion().then((v) => {
|
||||
_appVersion = v;
|
||||
});
|
||||
function useAppVersion() {
|
||||
const [version, setVersion] = useState(_appVersion);
|
||||
useEffect(() => {
|
||||
|
|
@ -214,8 +221,10 @@ export function Sidebar() {
|
|||
const active = s.files.find((f) => f.id === s.activeFileId);
|
||||
return active?.content ?? "";
|
||||
});
|
||||
const requestJumpToPosition = useDocumentStore((s) => s.requestJumpToPosition);
|
||||
const insertAtCursor = useDocumentStore((s) => s.insertAtCursor);
|
||||
const requestJumpToPosition = useDocumentStore(
|
||||
(s) => s.requestJumpToPosition,
|
||||
);
|
||||
const _insertAtCursor = useDocumentStore((s) => s.insertAtCursor);
|
||||
const moveFile = useDocumentStore((s) => s.moveFile);
|
||||
const moveFolder = useDocumentStore((s) => s.moveFolder);
|
||||
const closeProject = useDocumentStore((s) => s.closeProject);
|
||||
|
|
@ -239,7 +248,9 @@ export function Sidebar() {
|
|||
const { type } = event.payload;
|
||||
|
||||
if (type === "over" || type === "enter") {
|
||||
const payload = event.payload as { position: { x: number; y: number } };
|
||||
const payload = event.payload as {
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
const { x, y } = payload.position;
|
||||
// Tauri reports physical pixels; elementFromPoint expects logical (CSS) pixels
|
||||
const logicalX = x / window.devicePixelRatio;
|
||||
|
|
@ -258,12 +269,17 @@ export function Sidebar() {
|
|||
}
|
||||
|
||||
// Walk up from the hovered element to find the closest drop-folder target
|
||||
const folderEl = el.closest("[data-drop-folder]") as HTMLElement | null;
|
||||
const folderEl = el.closest(
|
||||
"[data-drop-folder]",
|
||||
) as HTMLElement | null;
|
||||
const folder = folderEl?.dataset.dropFolder ?? "__root__";
|
||||
nativeDropTargetRef.current = folder;
|
||||
setNativeDragOver(folder);
|
||||
} else if (type === "drop") {
|
||||
const payload = event.payload as { paths: string[]; position: { x: number; y: number } };
|
||||
const payload = event.payload as {
|
||||
paths: string[];
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
const { paths, position } = payload;
|
||||
const logicalX = position.x / window.devicePixelRatio;
|
||||
const logicalY = position.y / window.devicePixelRatio;
|
||||
|
|
@ -277,13 +293,16 @@ export function Sidebar() {
|
|||
return;
|
||||
}
|
||||
|
||||
const targetFolder = nativeDropTargetRef.current === "__root__"
|
||||
? undefined
|
||||
: (nativeDropTargetRef.current ?? undefined);
|
||||
const targetFolder =
|
||||
nativeDropTargetRef.current === "__root__"
|
||||
? undefined
|
||||
: (nativeDropTargetRef.current ?? undefined);
|
||||
|
||||
// Mark as handled so chat-composer doesn't also process it
|
||||
(window as any).__sidebarHandledDrop = true;
|
||||
setTimeout(() => { (window as any).__sidebarHandledDrop = false; }, 200);
|
||||
setTimeout(() => {
|
||||
(window as any).__sidebarHandledDrop = false;
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
await importFiles(paths, targetFolder);
|
||||
|
|
@ -313,7 +332,9 @@ export function Sidebar() {
|
|||
}, [importFiles]);
|
||||
|
||||
// Track selected folder for paste target
|
||||
const [pasteTargetFolder, setPasteTargetFolder] = useState<string | undefined>();
|
||||
const [pasteTargetFolder, setPasteTargetFolder] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
// ─── Cmd+V paste files from OS clipboard ───
|
||||
useEffect(() => {
|
||||
|
|
@ -349,47 +370,63 @@ export function Sidebar() {
|
|||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
const [activeDrag, setActiveDrag] = useState<{ id: string; type: "file" | "folder"; name: string } | null>(null);
|
||||
const [activeDrag, setActiveDrag] = useState<{
|
||||
id: string;
|
||||
type: "file" | "folder";
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const { type, name } = event.active.data.current as { type: "file" | "folder"; name: string };
|
||||
const { type, name } = event.active.data.current as {
|
||||
type: "file" | "folder";
|
||||
name: string;
|
||||
};
|
||||
setActiveDrag({ id: event.active.id as string, type, name });
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
||||
setActiveDrag(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
setActiveDrag(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const draggedPath = active.id as string;
|
||||
const draggedType = (active.data.current as { type: string }).type;
|
||||
const targetId = over.id as string;
|
||||
const targetFolder = targetId === "__root__" ? null : targetId;
|
||||
const draggedPath = active.id as string;
|
||||
const draggedType = (active.data.current as { type: string }).type;
|
||||
const targetId = over.id as string;
|
||||
const targetFolder = targetId === "__root__" ? null : targetId;
|
||||
|
||||
// Don't move if same parent
|
||||
const draggedParent = draggedPath.includes("/")
|
||||
? draggedPath.substring(0, draggedPath.lastIndexOf("/"))
|
||||
: null;
|
||||
if (targetFolder === draggedParent) return;
|
||||
// Don't move if same parent
|
||||
const draggedParent = draggedPath.includes("/")
|
||||
? draggedPath.substring(0, draggedPath.lastIndexOf("/"))
|
||||
: null;
|
||||
if (targetFolder === draggedParent) return;
|
||||
|
||||
// Don't move folder into itself or descendant
|
||||
if (draggedType === "folder" && targetFolder) {
|
||||
if (targetFolder === draggedPath || targetFolder.startsWith(draggedPath + "/")) return;
|
||||
}
|
||||
// Don't move folder into itself or descendant
|
||||
if (draggedType === "folder" && targetFolder) {
|
||||
if (
|
||||
targetFolder === draggedPath ||
|
||||
targetFolder.startsWith(`${draggedPath}/`)
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (draggedType === "file") await moveFile(draggedPath, targetFolder);
|
||||
else await moveFolder(draggedPath, targetFolder);
|
||||
} catch (err) {
|
||||
log.error("DnD move failed", { error: String(err) });
|
||||
}
|
||||
}, [moveFile, moveFolder]);
|
||||
try {
|
||||
if (draggedType === "file") await moveFile(draggedPath, targetFolder);
|
||||
else await moveFolder(draggedPath, targetFolder);
|
||||
} catch (err) {
|
||||
log.error("DnD move failed", { error: String(err) });
|
||||
}
|
||||
},
|
||||
[moveFile, moveFolder],
|
||||
);
|
||||
|
||||
// Dialog state
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [addDialogFolder, setAddDialogFolder] = useState<string | undefined>();
|
||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||
const [folderDialogParent, setFolderDialogParent] = useState<string | undefined>();
|
||||
const [folderDialogParent, setFolderDialogParent] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [renameFileId, setRenameFileId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
|
|
@ -397,7 +434,9 @@ export function Sidebar() {
|
|||
const [newFolderName, setNewFolderName] = useState("");
|
||||
|
||||
// Folder expand/collapse
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const tree = useMemo(() => buildFileTree(files, folders), [files, folders]);
|
||||
|
||||
// Auto-expand parent folders of the active file so it stays visible
|
||||
|
|
@ -410,7 +449,10 @@ export function Sidebar() {
|
|||
let changed = false;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const folder = parts.slice(0, i).join("/");
|
||||
if (!next.has(folder)) { next.add(folder); changed = true; }
|
||||
if (!next.has(folder)) {
|
||||
next.add(folder);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
|
@ -427,7 +469,10 @@ export function Sidebar() {
|
|||
}, []);
|
||||
|
||||
// Outline
|
||||
const toc = useMemo(() => parseTableOfContents(activeFileContent), [activeFileContent]);
|
||||
const toc = useMemo(
|
||||
() => parseTableOfContents(activeFileContent),
|
||||
[activeFileContent],
|
||||
);
|
||||
const handleTocClick = useCallback(
|
||||
(line: number) => {
|
||||
const lines = activeFileContent.split("\n");
|
||||
|
|
@ -442,7 +487,9 @@ export function Sidebar() {
|
|||
|
||||
// Check if a name already exists in the given folder
|
||||
// Case-insensitive on macOS/Windows (default case-insensitive filesystems)
|
||||
const isCaseInsensitiveFs = navigator.platform.startsWith("Mac") || navigator.platform.startsWith("Win");
|
||||
const isCaseInsensitiveFs =
|
||||
navigator.platform.startsWith("Mac") ||
|
||||
navigator.platform.startsWith("Win");
|
||||
const nameExistsIn = useCallback(
|
||||
(name: string, folder?: string) => {
|
||||
const targetPath = folder ? `${folder}/${name}` : name;
|
||||
|
|
@ -468,8 +515,11 @@ export function Sidebar() {
|
|||
// Auto-append .tex if no extension provided
|
||||
const finalName = /\.\w+$/.test(name) ? name : `${name}.tex`;
|
||||
const lower = finalName.toLowerCase();
|
||||
const type: "tex" | "image" =
|
||||
/\.(png|jpg|jpeg|gif|svg|bmp|webp)$/.test(lower) ? "image" : "tex";
|
||||
const type: "tex" | "image" = /\.(png|jpg|jpeg|gif|svg|bmp|webp)$/.test(
|
||||
lower,
|
||||
)
|
||||
? "image"
|
||||
: "tex";
|
||||
createNewFile(finalName, type, addDialogFolder);
|
||||
setNewFileName("");
|
||||
setNameError("");
|
||||
|
|
@ -497,7 +547,23 @@ export function Sidebar() {
|
|||
filters: [
|
||||
{
|
||||
name: "All Files",
|
||||
extensions: ["tex", "bib", "sty", "cls", "bst", "png", "jpg", "jpeg", "gif", "svg", "bmp", "webp", "pdf", "txt", "md"],
|
||||
extensions: [
|
||||
"tex",
|
||||
"bib",
|
||||
"sty",
|
||||
"cls",
|
||||
"bst",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"svg",
|
||||
"bmp",
|
||||
"webp",
|
||||
"pdf",
|
||||
"txt",
|
||||
"md",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -555,7 +621,7 @@ export function Sidebar() {
|
|||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||
{/* Header — padded top for macOS overlay titlebar */}
|
||||
<div className="relative flex items-center justify-center border-sidebar-border border-b px-3 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
|
||||
<div className="relative flex h-[calc(48px+var(--titlebar-height))] items-center justify-center border-sidebar-border border-b px-3 pt-[var(--titlebar-height)]">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="font-semibold text-sm">ClaudePrism</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
|
|
@ -579,7 +645,11 @@ export function Sidebar() {
|
|||
<PanelGroup direction="vertical" className="min-h-0 flex-1">
|
||||
{/* Files */}
|
||||
<Panel defaultSize={50} minSize={15}>
|
||||
<div ref={sidebarFilesRef} className="flex h-full flex-col" data-sidebar-files>
|
||||
<div
|
||||
ref={sidebarFilesRef}
|
||||
className="flex h-full flex-col"
|
||||
data-sidebar-files
|
||||
>
|
||||
<div className="relative flex h-8 shrink-0 items-center justify-center border-sidebar-border border-b px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderIcon className="size-3.5 text-muted-foreground" />
|
||||
|
|
@ -597,7 +667,12 @@ export function Sidebar() {
|
|||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-5" title="Add">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
title="Add"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -619,7 +694,11 @@ export function Sidebar() {
|
|||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<DroppableRoot nativeDragOver={nativeDragOver === "__root__"}>
|
||||
|
|
@ -632,7 +711,9 @@ export function Sidebar() {
|
|||
expandedFolders={expandedFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
onSelectFile={(id: string) => {
|
||||
const parent = id.includes("/") ? id.substring(0, id.lastIndexOf("/")) : undefined;
|
||||
const parent = id.includes("/")
|
||||
? id.substring(0, id.lastIndexOf("/"))
|
||||
: undefined;
|
||||
setPasteTargetFolder(parent);
|
||||
setActiveFile(id);
|
||||
}}
|
||||
|
|
@ -667,10 +748,11 @@ export function Sidebar() {
|
|||
<DragOverlay dropAnimation={null}>
|
||||
{activeDrag && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-sidebar px-2 py-1 text-sm shadow-lg ring-1 ring-ring">
|
||||
{activeDrag.type === "folder"
|
||||
? <FolderIcon className="size-4 shrink-0" />
|
||||
: <FileTextIcon className="size-4 shrink-0" />
|
||||
}
|
||||
{activeDrag.type === "folder" ? (
|
||||
<FolderIcon className="size-4 shrink-0" />
|
||||
) : (
|
||||
<FileTextIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{activeDrag.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -782,19 +864,26 @@ export function Sidebar() {
|
|||
<Input
|
||||
placeholder="filename.tex"
|
||||
value={newFileName}
|
||||
onChange={(e) => { setNewFileName(e.target.value); setNameError(""); }}
|
||||
onChange={(e) => {
|
||||
setNewFileName(e.target.value);
|
||||
setNameError("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddFile();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{nameError && <p className="text-destructive text-xs">{nameError}</p>}
|
||||
{nameError && (
|
||||
<p className="text-destructive text-xs">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddFile} disabled={!newFileName.trim()}>Create</Button>
|
||||
<Button onClick={handleAddFile} disabled={!newFileName.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -811,19 +900,32 @@ export function Sidebar() {
|
|||
<Input
|
||||
placeholder="folder name"
|
||||
value={newFolderName}
|
||||
onChange={(e) => { setNewFolderName(e.target.value); setNameError(""); }}
|
||||
onChange={(e) => {
|
||||
setNewFolderName(e.target.value);
|
||||
setNameError("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateFolder();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{nameError && <p className="text-destructive text-xs">{nameError}</p>}
|
||||
{nameError && (
|
||||
<p className="text-destructive text-xs">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFolderDialogOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setFolderDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>Create</Button>
|
||||
<Button
|
||||
onClick={handleCreateFolder}
|
||||
disabled={!newFolderName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -837,16 +939,24 @@ export function Sidebar() {
|
|||
<div className="space-y-2 py-4">
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => { setRenameValue(e.target.value); setNameError(""); }}
|
||||
onChange={(e) => {
|
||||
setRenameValue(e.target.value);
|
||||
setNameError("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{nameError && <p className="text-destructive text-xs">{nameError}</p>}
|
||||
{nameError && (
|
||||
<p className="text-destructive text-xs">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRenameDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRename}>Rename</Button>
|
||||
|
|
@ -861,7 +971,13 @@ export function Sidebar() {
|
|||
|
||||
// ─── dnd-kit helpers ───
|
||||
|
||||
function DroppableRoot({ children, nativeDragOver }: { children: React.ReactNode; nativeDragOver?: boolean }) {
|
||||
function DroppableRoot({
|
||||
children,
|
||||
nativeDragOver,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
nativeDragOver?: boolean;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "__root__" });
|
||||
return (
|
||||
<div
|
||||
|
|
@ -877,10 +993,22 @@ function DroppableRoot({ children, nativeDragOver }: { children: React.ReactNode
|
|||
);
|
||||
}
|
||||
|
||||
function DroppableFolder({ id, children, nativeDragOver }: { id: string; children: React.ReactNode; nativeDragOver?: boolean }) {
|
||||
function DroppableFolder({
|
||||
id,
|
||||
children,
|
||||
nativeDragOver,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
nativeDragOver?: boolean;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
return (
|
||||
<div ref={setNodeRef} data-drop-folder={id} className={cn((isOver || nativeDragOver) && "bg-accent/30 rounded-md")}>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-drop-folder={id}
|
||||
className={cn((isOver || nativeDragOver) && "rounded-md bg-accent/30")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -925,7 +1053,10 @@ function FileTreeNode({
|
|||
|
||||
if (node.type === "folder") {
|
||||
return (
|
||||
<DroppableFolder id={node.relativePath} nativeDragOver={nativeDragOver === node.relativePath}>
|
||||
<DroppableFolder
|
||||
id={node.relativePath}
|
||||
nativeDragOver={nativeDragOver === node.relativePath}
|
||||
>
|
||||
<DraggableItem id={node.relativePath} type="folder" name={node.name}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
|
|
@ -957,7 +1088,9 @@ function FileTreeNode({
|
|||
Import File Here
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onRename(node.relativePath, node.name)}>
|
||||
<ContextMenuItem
|
||||
onClick={() => onRename(node.relativePath, node.name)}
|
||||
>
|
||||
<PencilIcon className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
|
|
@ -1017,7 +1150,10 @@ function FileTreeNode({
|
|||
{getFileIcon(file)}
|
||||
<span className="min-w-0 flex-1 truncate">{node.name}</span>
|
||||
{file.isDirty && (
|
||||
<span className="ml-auto shrink-0 size-2 rounded-full bg-blue-500" title="Modified" />
|
||||
<span
|
||||
className="ml-auto size-2 shrink-0 rounded-full bg-blue-500"
|
||||
title="Modified"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
|
|
@ -1060,17 +1196,23 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) {
|
|||
|
||||
const checkSkillsStatus = useCallback(async () => {
|
||||
try {
|
||||
const globalStatus = await invoke<SkillsStatus>("check_skills_installed", {
|
||||
projectPath: null,
|
||||
});
|
||||
const globalStatus = await invoke<SkillsStatus>(
|
||||
"check_skills_installed",
|
||||
{
|
||||
projectPath: null,
|
||||
},
|
||||
);
|
||||
if (globalStatus.installed) {
|
||||
setSkillsStatus(globalStatus);
|
||||
return;
|
||||
}
|
||||
if (projectPath) {
|
||||
const projectStatus = await invoke<SkillsStatus>("check_skills_installed", {
|
||||
projectPath,
|
||||
});
|
||||
const projectStatus = await invoke<SkillsStatus>(
|
||||
"check_skills_installed",
|
||||
{
|
||||
projectPath,
|
||||
},
|
||||
);
|
||||
setSkillsStatus(projectStatus);
|
||||
} else {
|
||||
setSkillsStatus(globalStatus);
|
||||
|
|
@ -1085,22 +1227,31 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) {
|
|||
}, [checkSkillsStatus]);
|
||||
|
||||
// Lazy import onboarding
|
||||
const [OnboardingComponent, setOnboardingComponent] = useState<React.ComponentType<{
|
||||
onClose: () => void;
|
||||
}> | null>(null);
|
||||
const [OnboardingComponent, setOnboardingComponent] =
|
||||
useState<React.ComponentType<{
|
||||
onClose: () => void;
|
||||
}> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showOnboarding && !OnboardingComponent) {
|
||||
import("@/components/scientific-skills/scientific-skills-onboarding").then(
|
||||
(mod) => setOnboardingComponent(() => mod.ScientificSkillsOnboarding)
|
||||
import(
|
||||
"@/components/scientific-skills/scientific-skills-onboarding"
|
||||
).then((mod) =>
|
||||
setOnboardingComponent(() => mod.ScientificSkillsOnboarding),
|
||||
);
|
||||
}
|
||||
}, [showOnboarding, OnboardingComponent]);
|
||||
|
||||
const pythonLabel =
|
||||
venvReady ? "Active" : uvStatus === "not-installed" ? "Not installed" : uvStatus === "ready" ? "No venv" : "";
|
||||
const skillsLabel =
|
||||
skillsStatus?.installed ? `${skillsStatus.skill_count} skills` : "Not installed";
|
||||
const pythonLabel = venvReady
|
||||
? "Active"
|
||||
: uvStatus === "not-installed"
|
||||
? "Not installed"
|
||||
: uvStatus === "ready"
|
||||
? "No venv"
|
||||
: "";
|
||||
const skillsLabel = skillsStatus?.installed
|
||||
? `${skillsStatus.skill_count} skills`
|
||||
: "Not installed";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -1109,33 +1260,60 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) {
|
|||
<AppWindowIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-xs">Environment</span>
|
||||
</div>
|
||||
<div className="px-1 pb-1.5 space-y-0.5">
|
||||
<div className="space-y-0.5 px-1 pb-1.5">
|
||||
{/* Python / uv row */}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50 min-w-0"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50"
|
||||
onClick={() => setShowUvDialog(true)}
|
||||
>
|
||||
<TerminalIcon className={cn("size-3.5 shrink-0", venvReady ? "text-foreground" : "text-muted-foreground")} />
|
||||
<span className="min-w-0 truncate text-xs flex-1">Python</span>
|
||||
<span className={cn("shrink-0 text-xs", venvReady ? "text-foreground" : "text-muted-foreground")}>
|
||||
<TerminalIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
venvReady ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">Python</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs",
|
||||
venvReady ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{pythonLabel}
|
||||
</span>
|
||||
</button>
|
||||
{/* Scientific Skills row */}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50 min-w-0"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50"
|
||||
onClick={() => setShowOnboarding(true)}
|
||||
>
|
||||
<FlaskConicalIcon className={cn("size-3.5 shrink-0", skillsStatus?.installed ? "text-foreground" : "text-muted-foreground")} />
|
||||
<span className="min-w-0 truncate text-xs flex-1">Skills</span>
|
||||
<span className={cn("shrink-0 text-xs", skillsStatus?.installed ? "text-foreground" : "text-muted-foreground")}>
|
||||
<FlaskConicalIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
skillsStatus?.installed
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">Skills</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs",
|
||||
skillsStatus?.installed
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{skillsLabel}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UvSetupDialog open={showUvDialog} onClose={() => setShowUvDialog(false)} />
|
||||
<UvSetupDialog
|
||||
open={showUvDialog}
|
||||
onClose={() => setShowUvDialog(false)}
|
||||
/>
|
||||
|
||||
{showOnboarding && OnboardingComponent && (
|
||||
<OnboardingComponent
|
||||
|
|
@ -1151,21 +1329,33 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) {
|
|||
|
||||
// ─── Draggable wrapper ───
|
||||
|
||||
function DraggableItem({ id, type, name, children }: { id: string; type: "file" | "folder"; name: string; children: React.ReactNode }) {
|
||||
function DraggableItem({
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
type: "file" | "folder";
|
||||
name: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id,
|
||||
data: { type, name },
|
||||
});
|
||||
|
||||
// Wrap listeners to log pointer events
|
||||
const wrappedListeners = listeners ? Object.fromEntries(
|
||||
Object.entries(listeners).map(([key, handler]) => [
|
||||
key,
|
||||
(e: React.PointerEvent) => {
|
||||
(handler as (e: React.PointerEvent) => void)(e);
|
||||
},
|
||||
]),
|
||||
) : {};
|
||||
const wrappedListeners = listeners
|
||||
? Object.fromEntries(
|
||||
Object.entries(listeners).map(([key, handler]) => [
|
||||
key,
|
||||
(e: React.PointerEvent) => {
|
||||
(handler as (e: React.PointerEvent) => void)(e);
|
||||
},
|
||||
]),
|
||||
)
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -37,21 +37,23 @@ const MYLIB_KEY = "__my_library__";
|
|||
|
||||
export function ZoteroPanel() {
|
||||
const isAuthenticated = useZoteroStore((s) => s.isAuthenticated);
|
||||
const username = useZoteroStore((s) => s.username);
|
||||
const _username = useZoteroStore((s) => s.username);
|
||||
const isValidating = useZoteroStore((s) => s.isValidating);
|
||||
const isSyncing = useZoteroStore((s) => s.isSyncing);
|
||||
const syncProgress = useZoteroStore((s) => s.syncProgress);
|
||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||
const allSyncedCollections = useZoteroStore((s) => s.syncedCollections);
|
||||
const syncedCollections = projectRoot ? (allSyncedCollections[projectRoot] ?? {}) : {};
|
||||
const syncedCollections = projectRoot
|
||||
? (allSyncedCollections[projectRoot] ?? {})
|
||||
: {};
|
||||
const error = useZoteroStore((s) => s.error);
|
||||
const collections = useZoteroStore((s) => s.collections);
|
||||
const isLoadingCollections = useZoteroStore((s) => s.isLoadingCollections);
|
||||
const connectWithOAuth = useZoteroStore((s) => s.connectWithOAuth);
|
||||
const cancelConnect = useZoteroStore((s) => s.cancelConnect);
|
||||
const disconnect = useZoteroStore((s) => s.disconnect);
|
||||
const _disconnect = useZoteroStore((s) => s.disconnect);
|
||||
const revalidate = useZoteroStore((s) => s.revalidate);
|
||||
const loadCollections = useZoteroStore((s) => s.loadCollections);
|
||||
const _loadCollections = useZoteroStore((s) => s.loadCollections);
|
||||
const importCollectionToBib = useZoteroStore((s) => s.importCollectionToBib);
|
||||
const syncCollectionBib = useZoteroStore((s) => s.syncCollectionBib);
|
||||
const removeCollection = useZoteroStore((s) => s.removeCollection);
|
||||
|
|
@ -88,7 +90,7 @@ export function ZoteroPanel() {
|
|||
|
||||
{/* Syncing progress */}
|
||||
{isSyncing && (
|
||||
<div className="mx-2 mb-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<div className="mx-2 mb-0.5 flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
{syncProgress
|
||||
? `${syncProgress.loaded}/${syncProgress.total}`
|
||||
|
|
@ -114,7 +116,7 @@ export function ZoteroPanel() {
|
|||
)}
|
||||
|
||||
{isLoadingCollections ? (
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-muted-foreground text-xs">
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
|
|
@ -173,7 +175,9 @@ export function ZoteroHeader() {
|
|||
onClick={loadCollections}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", isLoadingCollections && "animate-spin")} />
|
||||
<RefreshCwIcon
|
||||
className={cn("size-3.5", isLoadingCollections && "animate-spin")}
|
||||
/>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -184,7 +188,9 @@ export function ZoteroHeader() {
|
|||
<DropdownMenuContent align="end" className="w-44">
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<UserIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="truncate text-xs text-muted-foreground">{username}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{username}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={disconnect}>
|
||||
|
|
@ -219,7 +225,7 @@ function NotConnectedView({
|
|||
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||
<LinkIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
Connect Zotero to import references.
|
||||
</p>
|
||||
{isValidating ? (
|
||||
|
|
@ -228,22 +234,32 @@ function NotConnectedView({
|
|||
<LoaderIcon className="size-3 animate-spin" />
|
||||
Authorizing...
|
||||
</div>
|
||||
<button className="text-[10px] text-muted-foreground underline" onClick={onCancel}>
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground underline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Button size="sm" className="h-6 gap-1 text-[11px]" onClick={onConnect}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 gap-1 text-[11px]"
|
||||
onClick={onConnect}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
Connect
|
||||
</Button>
|
||||
<button className="text-[10px] text-muted-foreground underline" onClick={onApiKey}>
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground underline"
|
||||
onClick={onApiKey}
|
||||
>
|
||||
API key
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-destructive text-[10px]">{error}</p>}
|
||||
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -251,7 +267,7 @@ function NotConnectedView({
|
|||
// ─── Collection Row ───
|
||||
|
||||
function CollectionRow({
|
||||
collectionKey,
|
||||
collectionKey: _collectionKey,
|
||||
name,
|
||||
icon,
|
||||
itemCount,
|
||||
|
|
@ -280,16 +296,18 @@ function CollectionRow({
|
|||
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-sm text-foreground">{name}</span>
|
||||
{isSynced && <CheckIcon className="size-2.5 shrink-0 text-muted-foreground" />}
|
||||
<span className="truncate text-foreground text-sm">{name}</span>
|
||||
{isSynced && (
|
||||
<CheckIcon className="size-2.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{isSynced && (
|
||||
<p className="truncate text-xs leading-none text-muted-foreground">
|
||||
<p className="truncate text-muted-foreground text-xs leading-none">
|
||||
{syncInfo.bibFileName}
|
||||
</p>
|
||||
)}
|
||||
{!isSynced && itemCount !== undefined && (
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
{itemCount} items
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -394,7 +412,10 @@ function ZoteroApiKeyDialog({
|
|||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!apiKey.trim() || isValidating}>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={!apiKey.trim() || isValidating}
|
||||
>
|
||||
{isValidating ? "Validating..." : "Connect"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import { useDocumentStore } from "@/stores/document-store";
|
|||
import { useHistoryStore } from "@/stores/history-store";
|
||||
import { useProposedChangesStore } from "@/stores/proposed-changes-store";
|
||||
import { readTexFileContent } from "@/lib/tauri/fs";
|
||||
import { compileLatex, resolveCompileTarget, formatCompileError } from "@/lib/latex-compiler";
|
||||
import {
|
||||
compileLatex,
|
||||
resolveCompileTarget,
|
||||
formatCompileError,
|
||||
} from "@/lib/latex-compiler";
|
||||
import { createLogger } from "@/lib/debug/logger";
|
||||
|
||||
const log = createLogger("claude-event");
|
||||
|
|
@ -138,34 +142,56 @@ export function useClaudeEvents() {
|
|||
lastMsgTimeRef.current.set(tabId, now);
|
||||
|
||||
// Log ALL message types with gap detection
|
||||
const contentTypes = msg.message?.content?.map((b: any) => b.type).join(",") ?? "";
|
||||
const contentTypes =
|
||||
msg.message?.content?.map((b: any) => b.type).join(",") ?? "";
|
||||
const gapWarning = Number(gap) > 10 ? ` GAP ${gap}s` : "";
|
||||
log.debug(`[${tabId}] ${elapsed(tabId)} #${count} type=${msg.type} sub=${msg.subtype ?? ""} content=[${contentTypes}] gap=${gap}s${gapWarning}`);
|
||||
log.debug(
|
||||
`[${tabId}] ${elapsed(tabId)} #${count} type=${msg.type} sub=${msg.subtype ?? ""} content=[${contentTypes}] gap=${gap}s${gapWarning}`,
|
||||
);
|
||||
|
||||
if (msg.type === "assistant") {
|
||||
const thinkingBlock = msg.message?.content?.find((b: any) => b.type === "thinking");
|
||||
const thinkingBlock = msg.message?.content?.find(
|
||||
(b: any) => b.type === "thinking",
|
||||
);
|
||||
if (thinkingBlock) {
|
||||
log.debug(`[${tabId}] ${elapsed(tabId)} thinking: ${(thinkingBlock.thinking || "").slice(0, 100)}`);
|
||||
log.debug(
|
||||
`[${tabId}] ${elapsed(tabId)} thinking: ${(thinkingBlock.thinking || "").slice(0, 100)}`,
|
||||
);
|
||||
}
|
||||
const textBlock = msg.message?.content?.find((b: any) => b.type === "text");
|
||||
const textBlock = msg.message?.content?.find(
|
||||
(b: any) => b.type === "text",
|
||||
);
|
||||
if (textBlock?.text) {
|
||||
log.debug(`[${tabId}] ${elapsed(tabId)} text: ${textBlock.text.slice(0, 100)}`);
|
||||
log.debug(
|
||||
`[${tabId}] ${elapsed(tabId)} text: ${textBlock.text.slice(0, 100)}`,
|
||||
);
|
||||
}
|
||||
const toolBlock = msg.message?.content?.find((b: any) => b.type === "tool_use");
|
||||
const toolBlock = msg.message?.content?.find(
|
||||
(b: any) => b.type === "tool_use",
|
||||
);
|
||||
if (toolBlock) {
|
||||
log.debug(`[${tabId}] ${elapsed(tabId)} tool_use: ${toolBlock.name} ${toolBlock.input?.file_path ?? ""}`);
|
||||
log.debug(
|
||||
`[${tabId}] ${elapsed(tabId)} tool_use: ${toolBlock.name} ${toolBlock.input?.file_path ?? ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (msg.type === "user" && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "tool_result") {
|
||||
const preview = typeof block.content === "string" ? block.content.slice(0, 80) : JSON.stringify(block.content)?.slice(0, 80);
|
||||
log.debug(`[${tabId}] ${elapsed(tabId)} tool_result: id=${block.tool_use_id} err=${block.is_error ?? false} len=${preview?.length ?? 0}`);
|
||||
const preview =
|
||||
typeof block.content === "string"
|
||||
? block.content.slice(0, 80)
|
||||
: JSON.stringify(block.content)?.slice(0, 80);
|
||||
log.debug(
|
||||
`[${tabId}] ${elapsed(tabId)} tool_result: id=${block.tool_use_id} err=${block.is_error ?? false} len=${preview?.length ?? 0}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg.type === "result") {
|
||||
log.info(`[${tabId}] ${elapsed(tabId)} result cost=$${msg.cost_usd} api=${msg.duration_api_ms}ms total=${msg.duration_ms}ms`);
|
||||
log.info(
|
||||
`[${tabId}] ${elapsed(tabId)} result cost=$${msg.cost_usd} api=${msg.duration_api_ms}ms total=${msg.duration_ms}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract session_id from system:init
|
||||
|
|
@ -177,10 +203,17 @@ export function useClaudeEvents() {
|
|||
if ((msg as any).type === "rate_limit_event") {
|
||||
const info = (msg as any).rate_limit_info;
|
||||
if (info) {
|
||||
const resetsAt = info.resetsAt ? new Date(info.resetsAt * 1000).toLocaleTimeString() : "unknown";
|
||||
log.warn(`[${tabId}] rate_limit: status=${info.status} type=${info.rateLimitType} resets=${resetsAt} overage=${info.overageStatus}`);
|
||||
const resetsAt = info.resetsAt
|
||||
? new Date(info.resetsAt * 1000).toLocaleTimeString()
|
||||
: "unknown";
|
||||
log.warn(
|
||||
`[${tabId}] rate_limit: status=${info.status} type=${info.rateLimitType} resets=${resetsAt} overage=${info.overageStatus}`,
|
||||
);
|
||||
if (info.status !== "allowed") {
|
||||
chatStore._setError(tabId, `Rate limited (${info.rateLimitType}). Resets at ${resetsAt}`);
|
||||
chatStore._setError(
|
||||
tabId,
|
||||
`Rate limited (${info.rateLimitType}). Resets at ${resetsAt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return; // rate_limit_event is informational — do not append to messages
|
||||
|
|
@ -240,7 +273,9 @@ export function useClaudeEvents() {
|
|||
(b: any) => b.type === "tool_use" && b.name === "AskUserQuestion",
|
||||
);
|
||||
if (hasAskUser) {
|
||||
log.info(`[${tabId}] ${elapsed(tabId)} AskUserQuestion detected — cancelling process for user input`);
|
||||
log.info(
|
||||
`[${tabId}] ${elapsed(tabId)} AskUserQuestion detected — cancelling process for user input`,
|
||||
);
|
||||
cancelledForAskRef.current.set(tabId, true);
|
||||
invoke("cancel_claude_execution", { tabId }).catch(() => {});
|
||||
}
|
||||
|
|
@ -251,18 +286,31 @@ export function useClaudeEvents() {
|
|||
const { tab_id: tabId, success } = payload;
|
||||
const count = msgCountRef.current.get(tabId) ?? 0;
|
||||
|
||||
log.info(`[${tabId}] complete success=${success} (${count} messages) cancelledForAsk=${cancelledForAskRef.current.get(tabId) ?? false}`);
|
||||
log.info(
|
||||
`[${tabId}] complete success=${success} (${count} messages) cancelledForAsk=${cancelledForAskRef.current.get(tabId) ?? false}`,
|
||||
);
|
||||
const chatStore = useClaudeChatStore.getState();
|
||||
|
||||
// Guard against duplicate complete events
|
||||
const tab = chatStore.tabs.find((t) => t.id === tabId);
|
||||
if (!tab?.isStreaming) {
|
||||
log.warn(`[${tabId}] ignoring duplicate complete event (not streaming)`);
|
||||
log.warn(
|
||||
`[${tabId}] ignoring duplicate complete event (not streaming)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!success && count > 0 && !tab.error && !cancelledForAskRef.current.get(tabId) && !chatStore._cancelledByUser) {
|
||||
chatStore._setError(tabId, "Claude process exited unexpectedly. This may be due to rate limiting or an API error.");
|
||||
if (
|
||||
!success &&
|
||||
count > 0 &&
|
||||
!tab.error &&
|
||||
!cancelledForAskRef.current.get(tabId) &&
|
||||
!chatStore._cancelledByUser
|
||||
) {
|
||||
chatStore._setError(
|
||||
tabId,
|
||||
"Claude process exited unexpectedly. This may be due to rate limiting or an API error.",
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up per-tab state
|
||||
|
|
@ -276,7 +324,9 @@ export function useClaudeEvents() {
|
|||
const projectPath = useDocumentStore.getState().projectRoot;
|
||||
if (projectPath) {
|
||||
try {
|
||||
await useHistoryStore.getState().createSnapshot(projectPath, "[claude] After Claude edit");
|
||||
await useHistoryStore
|
||||
.getState()
|
||||
.createSnapshot(projectPath, "[claude] After Claude edit");
|
||||
} catch {
|
||||
// snapshot failure should not break the flow
|
||||
}
|
||||
|
|
@ -286,7 +336,12 @@ export function useClaudeEvents() {
|
|||
await docStore.refreshFiles();
|
||||
|
||||
// Auto-recompile after Claude finishes
|
||||
const { projectRoot, files, activeFileId, isCompiling: alreadyCompiling } = useDocumentStore.getState();
|
||||
const {
|
||||
projectRoot,
|
||||
files,
|
||||
activeFileId,
|
||||
isCompiling: alreadyCompiling,
|
||||
} = useDocumentStore.getState();
|
||||
if (projectRoot && !alreadyCompiling) {
|
||||
const resolved = resolveCompileTarget(activeFileId, files);
|
||||
if (resolved) {
|
||||
|
|
@ -298,7 +353,9 @@ export function useClaudeEvents() {
|
|||
const pdfData = await compileLatex(projectRoot, targetPath);
|
||||
useDocumentStore.getState().setPdfData(pdfData, rootId);
|
||||
} catch (err) {
|
||||
useDocumentStore.getState().setCompileError(formatCompileError(err), rootId);
|
||||
useDocumentStore
|
||||
.getState()
|
||||
.setCompileError(formatCompileError(err), rootId);
|
||||
} finally {
|
||||
useDocumentStore.getState().setIsCompiling(false);
|
||||
}
|
||||
|
|
@ -321,7 +378,10 @@ export function useClaudeEvents() {
|
|||
if (!cancelled) handleStreamMessage(event.payload);
|
||||
},
|
||||
);
|
||||
if (cancelled) { unlistenOutput(); return; }
|
||||
if (cancelled) {
|
||||
unlistenOutput();
|
||||
return;
|
||||
}
|
||||
listenersRef.current.push(unlistenOutput);
|
||||
|
||||
const unlistenComplete = await listen<ClaudeCompletePayload>(
|
||||
|
|
@ -330,7 +390,10 @@ export function useClaudeEvents() {
|
|||
if (!cancelled) handleComplete(event.payload);
|
||||
},
|
||||
);
|
||||
if (cancelled) { unlistenComplete(); return; }
|
||||
if (cancelled) {
|
||||
unlistenComplete();
|
||||
return;
|
||||
}
|
||||
listenersRef.current.push(unlistenComplete);
|
||||
|
||||
const unlistenError = await listen<ClaudeErrorPayload>(
|
||||
|
|
@ -339,13 +402,21 @@ export function useClaudeEvents() {
|
|||
if (!cancelled) {
|
||||
const { tab_id: tabId, data: payload } = event.payload;
|
||||
log.warn(`[${tabId}] stderr: ${payload}`);
|
||||
if (payload.includes("Error") || payload.includes("error") || payload.includes("ECONNREFUSED") || payload.includes("timeout")) {
|
||||
if (
|
||||
payload.includes("Error") ||
|
||||
payload.includes("error") ||
|
||||
payload.includes("ECONNREFUSED") ||
|
||||
payload.includes("timeout")
|
||||
) {
|
||||
log.error(`[${tabId}] CRITICAL: ${payload}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (cancelled) { unlistenError(); return; }
|
||||
if (cancelled) {
|
||||
unlistenError();
|
||||
return;
|
||||
}
|
||||
listenersRef.current.push(unlistenError);
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,19 +14,32 @@ export function useKeyboardShortcuts() {
|
|||
});
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.shiftKey &&
|
||||
e.key.toLowerCase() === "n"
|
||||
) {
|
||||
e.preventDefault();
|
||||
invoke("create_new_window").catch(console.error);
|
||||
}
|
||||
|
||||
// Cmd+X (macOS) / Ctrl+X (others): Capture & Ask
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "x" && !e.shiftKey && !e.altKey) {
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key.toLowerCase() === "x" &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent("toggle-capture-mode"));
|
||||
}
|
||||
|
||||
// Cmd+Shift+D (macOS) / Ctrl+Shift+D (others): Open debug window
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "d") {
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.shiftKey &&
|
||||
e.key.toLowerCase() === "d"
|
||||
) {
|
||||
e.preventDefault();
|
||||
invoke("open_debug_window").catch(console.error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,12 @@ function _pushEntry(entry: LogEntry) {
|
|||
interface LogStore {
|
||||
/** Incremented on each log/clear — subscribers use this to know when to re-read. */
|
||||
version: number;
|
||||
log: (level: LogLevel, source: string, message: string, data?: unknown) => void;
|
||||
log: (
|
||||
level: LogLevel,
|
||||
source: string,
|
||||
message: string,
|
||||
data?: unknown,
|
||||
) => void;
|
||||
getEntries: () => readonly LogEntry[];
|
||||
getFilteredLogs: (opts?: {
|
||||
level?: LogLevel;
|
||||
|
|
@ -94,7 +99,7 @@ export const useLogStore = create<LogStore>((set) => ({
|
|||
// Forward warn/error to Rust stderr via existing js_log command
|
||||
if (level === "warn" || level === "error") {
|
||||
const prefix = level === "error" ? "ERROR" : "WARN";
|
||||
const msg = `[${prefix}][${source}] ${message}${data ? " " + JSON.stringify(data) : ""}`;
|
||||
const msg = `[${prefix}][${source}] ${message}${data ? ` ${JSON.stringify(data)}` : ""}`;
|
||||
invoke("js_log", { msg }).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
|
@ -172,11 +177,14 @@ export function getGpuRenderer(): string {
|
|||
if (_gpuRendererCache !== null) return _gpuRendererCache;
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
||||
const gl =
|
||||
canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
||||
if (gl && gl instanceof WebGLRenderingContext) {
|
||||
const ext = gl.getExtension("WEBGL_debug_renderer_info");
|
||||
if (ext) {
|
||||
_gpuRendererCache = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string;
|
||||
_gpuRendererCache = gl.getParameter(
|
||||
ext.UNMASKED_RENDERER_WEBGL,
|
||||
) as string;
|
||||
return _gpuRendererCache;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ function bonusFor(prev: string, curr: string): number {
|
|||
if (prev === "/") return SCORE_MATCH_SLASH;
|
||||
if (prev === "-" || prev === "_" || prev === " ") return SCORE_MATCH_WORD;
|
||||
if (prev === ".") return SCORE_MATCH_DOT;
|
||||
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
|
||||
if (prev === prev.toLowerCase() && curr === curr.toUpperCase())
|
||||
return SCORE_MATCH_CAPITAL;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -52,12 +53,15 @@ function fuzzyScore(query: string, candidate: string): number {
|
|||
if (qLower[i] === cLower[j]) {
|
||||
let score = 0;
|
||||
if (i === 0) {
|
||||
score = j === 0
|
||||
? SCORE_MATCH_CONSECUTIVE
|
||||
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) + bonusFor(candidate[j - 1], candidate[j]);
|
||||
score =
|
||||
j === 0
|
||||
? SCORE_MATCH_CONSECUTIVE
|
||||
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) +
|
||||
bonusFor(candidate[j - 1], candidate[j]);
|
||||
} else if (j > 0) {
|
||||
const consecutive = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
|
||||
const boundary = M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
|
||||
const boundary =
|
||||
M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
|
||||
score = Math.max(consecutive, boundary);
|
||||
}
|
||||
D[i][j] = score;
|
||||
|
|
@ -127,7 +131,7 @@ function scoreCommand(cmd: FakeCmd, q: string): number {
|
|||
|
||||
// Description: only substring (contains) match to avoid false positives
|
||||
let descScore = -Infinity;
|
||||
if (cmd.description && cmd.description.toLowerCase().includes(q.toLowerCase())) {
|
||||
if (cmd.description?.toLowerCase().includes(q.toLowerCase())) {
|
||||
descScore = q.length * 0.3;
|
||||
}
|
||||
|
||||
|
|
@ -140,20 +144,76 @@ function scoreCommand(cmd: FakeCmd, q: string): number {
|
|||
// ─── Test data ───
|
||||
|
||||
const COMMANDS: FakeCmd[] = [
|
||||
{ name: "biorxiv-database", full_command: "/biorxiv-database", description: "Efficient database search tool for bioRxiv preprint server." },
|
||||
{ name: "biopython", full_command: "/biopython", description: "Comprehensive molecular biology toolkit." },
|
||||
{ name: "bioservices", full_command: "/bioservices", description: "Unified Python interface to 40+ bioinformatics services." },
|
||||
{ name: "cbioportal-database", full_command: "/cbioportal-database", description: "Query cBioPortal for cancer genomics data." },
|
||||
{ name: "scikit-bio", full_command: "/scikit-bio", description: "Biological data toolkit." },
|
||||
{ name: "scvi-tools", full_command: "/scvi-tools", description: "Deep generative models for single-cell omics." },
|
||||
{ name: "vaex", full_command: "/vaex", description: "Large tabular datasets." },
|
||||
{ name: "deepchem", full_command: "/deepchem", description: "Molecular ML with diverse featurizers." },
|
||||
{ name: "market-research-reports", full_command: "/market-research-reports", description: "Market research reports." },
|
||||
{ name: "matlab", full_command: "/matlab", description: "MATLAB and GNU Octave." },
|
||||
{ name: "scanpy", full_command: "/scanpy", description: "scRNA-seq analysis." },
|
||||
{ name: "phylogenetics", full_command: "/phylogenetics", description: "Phylogenetic trees." },
|
||||
{ name: "perplexity-search", full_command: "/perplexity-search", description: "AI-powered web searches." },
|
||||
{ name: "latchbio-integration", full_command: "/latchbio-integration", description: "Latch platform for bioinformatics." },
|
||||
{
|
||||
name: "biorxiv-database",
|
||||
full_command: "/biorxiv-database",
|
||||
description: "Efficient database search tool for bioRxiv preprint server.",
|
||||
},
|
||||
{
|
||||
name: "biopython",
|
||||
full_command: "/biopython",
|
||||
description: "Comprehensive molecular biology toolkit.",
|
||||
},
|
||||
{
|
||||
name: "bioservices",
|
||||
full_command: "/bioservices",
|
||||
description: "Unified Python interface to 40+ bioinformatics services.",
|
||||
},
|
||||
{
|
||||
name: "cbioportal-database",
|
||||
full_command: "/cbioportal-database",
|
||||
description: "Query cBioPortal for cancer genomics data.",
|
||||
},
|
||||
{
|
||||
name: "scikit-bio",
|
||||
full_command: "/scikit-bio",
|
||||
description: "Biological data toolkit.",
|
||||
},
|
||||
{
|
||||
name: "scvi-tools",
|
||||
full_command: "/scvi-tools",
|
||||
description: "Deep generative models for single-cell omics.",
|
||||
},
|
||||
{
|
||||
name: "vaex",
|
||||
full_command: "/vaex",
|
||||
description: "Large tabular datasets.",
|
||||
},
|
||||
{
|
||||
name: "deepchem",
|
||||
full_command: "/deepchem",
|
||||
description: "Molecular ML with diverse featurizers.",
|
||||
},
|
||||
{
|
||||
name: "market-research-reports",
|
||||
full_command: "/market-research-reports",
|
||||
description: "Market research reports.",
|
||||
},
|
||||
{
|
||||
name: "matlab",
|
||||
full_command: "/matlab",
|
||||
description: "MATLAB and GNU Octave.",
|
||||
},
|
||||
{
|
||||
name: "scanpy",
|
||||
full_command: "/scanpy",
|
||||
description: "scRNA-seq analysis.",
|
||||
},
|
||||
{
|
||||
name: "phylogenetics",
|
||||
full_command: "/phylogenetics",
|
||||
description: "Phylogenetic trees.",
|
||||
},
|
||||
{
|
||||
name: "perplexity-search",
|
||||
full_command: "/perplexity-search",
|
||||
description: "AI-powered web searches.",
|
||||
},
|
||||
{
|
||||
name: "latchbio-integration",
|
||||
full_command: "/latchbio-integration",
|
||||
description: "Latch platform for bioinformatics.",
|
||||
},
|
||||
];
|
||||
|
||||
function search(q: string): { name: string; score: number }[] {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ export function resolveCompileTarget(
|
|||
return { rootId, targetPath: rootEntry.relativePath };
|
||||
}
|
||||
// Fallback: look for any well-known root tex file
|
||||
const fallback = files.find((f) => f.name === "main.tex" || f.name === "document.tex");
|
||||
const fallback = files.find(
|
||||
(f) => f.name === "main.tex" || f.name === "document.tex",
|
||||
);
|
||||
if (fallback) {
|
||||
return { rootId: fallback.id, targetPath: fallback.relativePath };
|
||||
}
|
||||
|
|
@ -51,7 +53,9 @@ export async function compileLatex(
|
|||
});
|
||||
|
||||
const result = new Uint8Array(buffer);
|
||||
log.info(`Compiled ${mainFile} in ${(performance.now() - start).toFixed(0)}ms (${(result.byteLength / 1024).toFixed(0)} KB)`);
|
||||
log.info(
|
||||
`Compiled ${mainFile} in ${(performance.now() - start).toFixed(0)}ms (${(result.byteLength / 1024).toFixed(0)} KB)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +78,8 @@ export async function synctexEdit(
|
|||
x,
|
||||
y,
|
||||
});
|
||||
if (result) log.debug(`SyncTeX: page ${page} → ${result.file}:${result.line}`);
|
||||
if (result)
|
||||
log.debug(`SyncTeX: page ${page} → ${result.file}:${result.line}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
log.debug("SyncTeX lookup failed", { page, error: String(err) });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { StructuredTextData, LinkData, PageSize, WorkerResponse } from "./types";
|
||||
import type {
|
||||
StructuredTextData,
|
||||
LinkData,
|
||||
PageSize,
|
||||
WorkerResponse,
|
||||
} from "./types";
|
||||
import { createLogger } from "@/lib/debug/logger";
|
||||
|
||||
const log = createLogger("mupdf-worker");
|
||||
|
|
@ -12,7 +17,11 @@ export interface MupdfClient {
|
|||
drawPage(docId: number, pageIndex: number, dpi: number): Promise<ImageData>;
|
||||
getPageText(docId: number, pageIndex: number): Promise<StructuredTextData>;
|
||||
getPageLinks(docId: number, pageIndex: number): Promise<LinkData[]>;
|
||||
renderThumbnail(docId: number, pageIndex: number, targetWidth: number): Promise<ArrayBuffer>;
|
||||
renderThumbnail(
|
||||
docId: number,
|
||||
pageIndex: number,
|
||||
targetWidth: number,
|
||||
): Promise<ArrayBuffer>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
|
|
@ -22,10 +31,9 @@ type PendingRequest = {
|
|||
};
|
||||
|
||||
function createClient(): MupdfClient {
|
||||
const worker = new Worker(
|
||||
new URL("./mupdf-worker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
const worker = new Worker(new URL("./mupdf-worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
const pending = new Map<number, PendingRequest>();
|
||||
let nextId = 1;
|
||||
|
|
@ -74,13 +82,23 @@ function createClient(): MupdfClient {
|
|||
const timer = setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`MuPDF worker timeout: ${method} took longer than ${CALL_TIMEOUT_MS}ms`));
|
||||
reject(
|
||||
new Error(
|
||||
`MuPDF worker timeout: ${method} took longer than ${CALL_TIMEOUT_MS}ms`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, CALL_TIMEOUT_MS);
|
||||
|
||||
pending.set(id, {
|
||||
resolve: (value: any) => { clearTimeout(timer); resolve(value); },
|
||||
reject: (error: Error) => { clearTimeout(timer); reject(error); },
|
||||
resolve: (value: any) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
const transferables: Transferable[] = [];
|
||||
|
|
@ -96,15 +114,18 @@ function createClient(): MupdfClient {
|
|||
}
|
||||
|
||||
return {
|
||||
openDocument: (buffer, magic = "application/pdf") => call("openDocument", buffer, magic),
|
||||
openDocument: (buffer, magic = "application/pdf") =>
|
||||
call("openDocument", buffer, magic),
|
||||
closeDocument: (docId) => call("closeDocument", docId),
|
||||
countPages: (docId) => call("countPages", docId),
|
||||
getPageSize: (docId, pageIndex) => call("getPageSize", docId, pageIndex),
|
||||
getAllPageSizes: (docId) => call("getAllPageSizes", docId),
|
||||
drawPage: (docId, pageIndex, dpi) => call("drawPage", docId, pageIndex, dpi),
|
||||
drawPage: (docId, pageIndex, dpi) =>
|
||||
call("drawPage", docId, pageIndex, dpi),
|
||||
getPageText: (docId, pageIndex) => call("getPageText", docId, pageIndex),
|
||||
getPageLinks: (docId, pageIndex) => call("getPageLinks", docId, pageIndex),
|
||||
renderThumbnail: (docId, pageIndex, targetWidth) => call("renderThumbnail", docId, pageIndex, targetWidth),
|
||||
renderThumbnail: (docId, pageIndex, targetWidth) =>
|
||||
call("renderThumbnail", docId, pageIndex, targetWidth),
|
||||
destroy: () => worker.terminate(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ methods.countPages = (docId: number): number => {
|
|||
return doc.countPages();
|
||||
};
|
||||
|
||||
methods.getPageSize = (docId: number, pageIndex: number): { width: number; height: number } => {
|
||||
methods.getPageSize = (
|
||||
docId: number,
|
||||
pageIndex: number,
|
||||
): { width: number; height: number } => {
|
||||
const doc = documentMap.get(docId)!;
|
||||
const page = doc.loadPage(pageIndex);
|
||||
const bounds = page.getBounds();
|
||||
|
|
@ -34,7 +37,9 @@ methods.getPageSize = (docId: number, pageIndex: number): { width: number; heigh
|
|||
};
|
||||
};
|
||||
|
||||
methods.getAllPageSizes = (docId: number): { width: number; height: number }[] => {
|
||||
methods.getAllPageSizes = (
|
||||
docId: number,
|
||||
): { width: number; height: number }[] => {
|
||||
const doc = documentMap.get(docId)!;
|
||||
const count = doc.countPages();
|
||||
const sizes: { width: number; height: number }[] = [];
|
||||
|
|
@ -49,7 +54,11 @@ methods.getAllPageSizes = (docId: number): { width: number; height: number }[] =
|
|||
return sizes;
|
||||
};
|
||||
|
||||
methods.drawPage = (docId: number, pageIndex: number, dpi: number): ImageData => {
|
||||
methods.drawPage = (
|
||||
docId: number,
|
||||
pageIndex: number,
|
||||
dpi: number,
|
||||
): ImageData => {
|
||||
const doc = documentMap.get(docId)!;
|
||||
const page = doc.loadPage(pageIndex);
|
||||
const scale = dpi / 72;
|
||||
|
|
@ -85,13 +94,21 @@ methods.getPageText = (docId: number, pageIndex: number): unknown => {
|
|||
bbox: block.bbox,
|
||||
lines: (block.lines || []).map((line: any) => {
|
||||
let text = "";
|
||||
let font = { name: "", family: "", size: 12, weight: "normal", style: "normal" };
|
||||
let font = {
|
||||
name: "",
|
||||
family: "",
|
||||
size: 12,
|
||||
weight: "normal",
|
||||
style: "normal",
|
||||
};
|
||||
let baselineY = 0;
|
||||
|
||||
const spans = line.spans || [];
|
||||
if (spans.length > 0) {
|
||||
text = spans
|
||||
.map((span: any) => (span.chars || []).map((ch: any) => ch.c).join(""))
|
||||
.map((span: any) =>
|
||||
(span.chars || []).map((ch: any) => ch.c).join(""),
|
||||
)
|
||||
.join("");
|
||||
|
||||
const firstSpan = spans[0];
|
||||
|
|
@ -162,7 +179,11 @@ methods.getPageLinks = (docId: number, pageIndex: number): unknown[] => {
|
|||
});
|
||||
};
|
||||
|
||||
methods.renderThumbnail = (docId: number, pageIndex: number, targetWidth: number): ArrayBuffer => {
|
||||
methods.renderThumbnail = (
|
||||
docId: number,
|
||||
pageIndex: number,
|
||||
targetWidth: number,
|
||||
): ArrayBuffer => {
|
||||
const doc = documentMap.get(docId)!;
|
||||
const page = doc.loadPage(pageIndex);
|
||||
const bounds = page.getBounds();
|
||||
|
|
@ -177,7 +198,7 @@ methods.renderThumbnail = (docId: number, pageIndex: number, targetWidth: number
|
|||
};
|
||||
|
||||
// RPC message handler
|
||||
onmessage = (event: MessageEvent) => {
|
||||
self.onmessage = (event: MessageEvent) => {
|
||||
const [func, id, args] = event.data as [string, number, unknown[]];
|
||||
try {
|
||||
const result = methods[func](...args);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ function computeFingerprint(data: Uint8Array): string {
|
|||
if (len < 16) return `${len}:${Array.from(data).join(",")}`;
|
||||
// Sample: length + first 8 bytes + middle 8 bytes + last 8 bytes
|
||||
const first = Array.from(data.subarray(0, 8));
|
||||
const mid = Array.from(data.subarray(Math.floor(len / 2) - 4, Math.floor(len / 2) + 4));
|
||||
const mid = Array.from(
|
||||
data.subarray(Math.floor(len / 2) - 4, Math.floor(len / 2) + 4),
|
||||
);
|
||||
const last = Array.from(data.subarray(len - 8));
|
||||
return `${len}:${first.join(",")}|${mid.join(",")}|${last.join(",")}`;
|
||||
}
|
||||
|
|
@ -39,7 +41,9 @@ async function evictOldest(): Promise<void> {
|
|||
const entry = cache.get(oldestKey)!;
|
||||
cache.delete(oldestKey);
|
||||
log.debug(`Evicted doc ${entry.docId} (cache size was ${cache.size + 1})`);
|
||||
await getMupdfClient().closeDocument(entry.docId).catch(() => {});
|
||||
await getMupdfClient()
|
||||
.closeDocument(entry.docId)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +72,9 @@ export function getCachedDocument(data: Uint8Array): DocCacheResult | null {
|
|||
* Returns the docId and pageSizes. If the same PDF bytes were already open,
|
||||
* reuses the existing document (cache hit).
|
||||
*/
|
||||
export async function getOrOpenDocument(data: Uint8Array): Promise<DocCacheResult> {
|
||||
export async function getOrOpenDocument(
|
||||
data: Uint8Array,
|
||||
): Promise<DocCacheResult> {
|
||||
// Reuse synchronous lookup to avoid duplicating fingerprint + cache logic
|
||||
const hit = getCachedDocument(data);
|
||||
if (hit) return hit;
|
||||
|
|
@ -77,7 +83,9 @@ export async function getOrOpenDocument(data: Uint8Array): Promise<DocCacheResul
|
|||
await evictOldest();
|
||||
const fingerprint = computeFingerprint(data);
|
||||
|
||||
log.debug(`Cache miss, opening document (${(data.byteLength / 1024).toFixed(0)} KB)`);
|
||||
log.debug(
|
||||
`Cache miss, opening document (${(data.byteLength / 1024).toFixed(0)} KB)`,
|
||||
);
|
||||
const client = getMupdfClient();
|
||||
// Always copy — the original buffer may not be transferable (e.g., from Tauri),
|
||||
// and transfer detaches the ArrayBuffer which would corrupt the pdfCache reference.
|
||||
|
|
@ -94,7 +102,9 @@ export async function getOrOpenDocument(data: Uint8Array): Promise<DocCacheResul
|
|||
lastAccess: Date.now(),
|
||||
});
|
||||
|
||||
log.info(`Opened doc ${docId}: ${pageSizes.length} pages, cache size=${cache.size}`);
|
||||
log.info(
|
||||
`Opened doc ${docId}: ${pageSizes.length} pages, cache size=${cache.size}`,
|
||||
);
|
||||
return { docId, pageSizes, cacheHit: false };
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +113,9 @@ export function invalidateDoc(docId: number): void {
|
|||
for (const [key, entry] of cache) {
|
||||
if (entry.docId === docId) {
|
||||
cache.delete(key);
|
||||
getMupdfClient().closeDocument(docId).catch(() => {});
|
||||
getMupdfClient()
|
||||
.closeDocument(docId)
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -113,8 +125,8 @@ export function invalidateDoc(docId: number): void {
|
|||
export async function clearDocCache(): Promise<void> {
|
||||
const count = cache.size;
|
||||
const client = getMupdfClient();
|
||||
const closePromises = [...cache.values()].map(
|
||||
(entry) => client.closeDocument(entry.docId).catch(() => {}),
|
||||
const closePromises = [...cache.values()].map((entry) =>
|
||||
client.closeDocument(entry.docId).catch(() => {}),
|
||||
);
|
||||
cache.clear();
|
||||
await Promise.all(closePromises);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,13 @@ export interface StructuredTextLine {
|
|||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
font: { name: string; family: string; size: number; weight: string; style: string };
|
||||
font: {
|
||||
name: string;
|
||||
family: string;
|
||||
size: number;
|
||||
weight: string;
|
||||
style: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StructuredTextBlock {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@ import { createLogger } from "@/lib/debug/logger";
|
|||
|
||||
const log = createLogger("fs");
|
||||
|
||||
export type ProjectFileType = "tex" | "image" | "pdf" | "bib" | "style" | "other";
|
||||
export type ProjectFileType =
|
||||
| "tex"
|
||||
| "image"
|
||||
| "pdf"
|
||||
| "bib"
|
||||
| "style"
|
||||
| "other";
|
||||
|
||||
export interface FsProjectFile {
|
||||
relativePath: string;
|
||||
|
|
@ -135,9 +141,7 @@ export interface ScanResult {
|
|||
folders: string[]; // relative paths of all directories
|
||||
}
|
||||
|
||||
export async function scanProjectFolder(
|
||||
rootPath: string,
|
||||
): Promise<ScanResult> {
|
||||
export async function scanProjectFolder(rootPath: string): Promise<ScanResult> {
|
||||
const files: FsProjectFile[] = [];
|
||||
const folders: string[] = [];
|
||||
|
||||
|
|
@ -164,7 +168,9 @@ export async function scanProjectFolder(
|
|||
try {
|
||||
const info = await stat(entryPath);
|
||||
fileSize = info.size;
|
||||
} catch { /* stat failed — treat as 0 */ }
|
||||
} catch {
|
||||
/* stat failed — treat as 0 */
|
||||
}
|
||||
}
|
||||
files.push({
|
||||
relativePath,
|
||||
|
|
@ -230,7 +236,10 @@ export async function createFileOnDisk(
|
|||
): Promise<string> {
|
||||
const fullPath = await join(rootPath, name);
|
||||
// Ensure parent directory exists
|
||||
const lastSep = Math.max(fullPath.lastIndexOf("/"), fullPath.lastIndexOf("\\"));
|
||||
const lastSep = Math.max(
|
||||
fullPath.lastIndexOf("/"),
|
||||
fullPath.lastIndexOf("\\"),
|
||||
);
|
||||
const parentDir = lastSep > 0 ? fullPath.substring(0, lastSep) : "";
|
||||
if (parentDir && !(await exists(parentDir))) {
|
||||
await mkdir(parentDir, { recursive: true });
|
||||
|
|
@ -275,7 +284,10 @@ export async function copyFileToProject(
|
|||
const uniqueName = await getUniqueTargetName(rootPath, targetName);
|
||||
const fullPath = await join(rootPath, uniqueName);
|
||||
// Ensure parent directory exists (e.g., attachments/)
|
||||
const lastSlash = Math.max(fullPath.lastIndexOf("/"), fullPath.lastIndexOf("\\"));
|
||||
const lastSlash = Math.max(
|
||||
fullPath.lastIndexOf("/"),
|
||||
fullPath.lastIndexOf("\\"),
|
||||
);
|
||||
if (lastSlash > 0) {
|
||||
const parentDir = fullPath.substring(0, lastSlash);
|
||||
if (!(await exists(parentDir))) {
|
||||
|
|
@ -291,7 +303,9 @@ export async function deleteFileFromDisk(absolutePath: string): Promise<void> {
|
|||
await remove(absolutePath);
|
||||
}
|
||||
|
||||
export async function deleteFolderFromDisk(absolutePath: string): Promise<void> {
|
||||
export async function deleteFolderFromDisk(
|
||||
absolutePath: string,
|
||||
): Promise<void> {
|
||||
log.debug(`Deleting folder: ${absolutePath}`);
|
||||
await remove(absolutePath, { recursive: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ export function getTemplatePdfUrl(templateId: string): string {
|
|||
/**
|
||||
* Load the static PDF and render page 1 as a thumbnail data URL.
|
||||
*/
|
||||
export async function generateThumbnail(templateId: string): Promise<string | null> {
|
||||
export async function generateThumbnail(
|
||||
templateId: string,
|
||||
): Promise<string | null> {
|
||||
const cached = thumbnailCache.get(templateId);
|
||||
if (cached) return cached;
|
||||
|
||||
|
|
@ -68,7 +70,9 @@ export async function generateThumbnail(templateId: string): Promise<string | nu
|
|||
notify();
|
||||
return dataUrl;
|
||||
} catch (err) {
|
||||
log.warn(`Failed to load preview for ${templateId}`, { error: String(err) });
|
||||
log.warn(`Failed to load preview for ${templateId}`, {
|
||||
error: String(err),
|
||||
});
|
||||
failedIds.add(templateId);
|
||||
notify();
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
// ─── Template Data Architecture ───
|
||||
|
||||
export type TemplateCategory = "academic" | "professional" | "creative" | "starter";
|
||||
export type TemplateCategory =
|
||||
| "academic"
|
||||
| "professional"
|
||||
| "creative"
|
||||
| "starter";
|
||||
|
||||
export type TemplateSubcategory =
|
||||
| "papers"
|
||||
|
|
@ -59,7 +63,10 @@ export const SUBCATEGORY_LABELS: Record<TemplateSubcategory, string> = {
|
|||
blank: "Blank",
|
||||
};
|
||||
|
||||
export const CATEGORY_SUBCATEGORIES: Record<TemplateCategory, TemplateSubcategory[]> = {
|
||||
export const CATEGORY_SUBCATEGORIES: Record<
|
||||
TemplateCategory,
|
||||
TemplateSubcategory[]
|
||||
> = {
|
||||
academic: ["papers", "theses", "presentations", "posters"],
|
||||
professional: ["cv", "letters", "reports"],
|
||||
creative: ["books", "newsletters"],
|
||||
|
|
@ -75,7 +82,15 @@ const TEMPLATES: TemplateDefinition[] = [
|
|||
description: "Academic paper with abstract, sections, and references",
|
||||
category: "academic",
|
||||
subcategory: "papers",
|
||||
tags: ["article", "research", "journal", "academic", "science", "abstract", "bibliography"],
|
||||
tags: [
|
||||
"article",
|
||||
"research",
|
||||
"journal",
|
||||
"academic",
|
||||
"science",
|
||||
"abstract",
|
||||
"bibliography",
|
||||
],
|
||||
icon: "FileText",
|
||||
documentClass: "article",
|
||||
mainFileName: "main.tex",
|
||||
|
|
@ -338,7 +353,13 @@ Barret Zoph and Quoc~V Le.
|
|||
description: "Two-column IEEE conference format with standard sections",
|
||||
category: "academic",
|
||||
subcategory: "papers",
|
||||
tags: ["ieee", "conference", "two-column", "engineering", "computer science"],
|
||||
tags: [
|
||||
"ieee",
|
||||
"conference",
|
||||
"two-column",
|
||||
"engineering",
|
||||
"computer science",
|
||||
],
|
||||
icon: "FileText",
|
||||
documentClass: "IEEEtran",
|
||||
mainFileName: "main.tex",
|
||||
|
|
@ -3197,7 +3218,7 @@ export const BIB_TEMPLATE = `% Add your references here
|
|||
|
||||
// ─── Registry API ───
|
||||
|
||||
let _templates = TEMPLATES;
|
||||
const _templates = TEMPLATES;
|
||||
|
||||
export function getAllTemplates(): TemplateDefinition[] {
|
||||
return _templates;
|
||||
|
|
@ -3207,7 +3228,9 @@ export function getTemplateById(id: string): TemplateDefinition | undefined {
|
|||
return _templates.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
export function getTemplatesByCategory(category: TemplateCategory): TemplateDefinition[] {
|
||||
export function getTemplatesByCategory(
|
||||
category: TemplateCategory,
|
||||
): TemplateDefinition[] {
|
||||
return _templates.filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,9 @@ function extractCitekey(bibtex: string): string {
|
|||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
export async function validateApiKey(apiKey: string): Promise<ZoteroCredentials> {
|
||||
export async function validateApiKey(
|
||||
apiKey: string,
|
||||
): Promise<ZoteroCredentials> {
|
||||
const response = await zoteroFetch(apiKey, "/keys/current");
|
||||
const data = await response.json();
|
||||
return {
|
||||
|
|
@ -98,7 +100,10 @@ export async function fetchCollections(
|
|||
apiKey: string,
|
||||
userID: string,
|
||||
): Promise<ZoteroCollection[]> {
|
||||
const response = await zoteroFetch(apiKey, `/users/${userID}/collections?format=json`);
|
||||
const response = await zoteroFetch(
|
||||
apiKey,
|
||||
`/users/${userID}/collections?format=json`,
|
||||
);
|
||||
const data = (await response.json()) as {
|
||||
key: string;
|
||||
data: { key: string; name: string; parentCollection: string | false };
|
||||
|
|
@ -146,7 +151,9 @@ export async function importCollection(
|
|||
|
||||
if (start === 0) {
|
||||
total = Number(response.headers.get("Total-Results") ?? 0);
|
||||
libraryVersion = Number(response.headers.get("Last-Modified-Version") ?? 0);
|
||||
libraryVersion = Number(
|
||||
response.headers.get("Last-Modified-Version") ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
const items = (await response.json()) as { key: string; bibtex?: string }[];
|
||||
|
|
@ -191,13 +198,19 @@ export async function syncCollection(
|
|||
|
||||
// For a specific collection, re-fetch all items and diff against keyMap
|
||||
// (Zotero API doesn't support `since` scoped to a collection)
|
||||
const result = await importCollection(apiKey, userID, collectionKey, onProgress);
|
||||
const result = await importCollection(
|
||||
apiKey,
|
||||
userID,
|
||||
collectionKey,
|
||||
onProgress,
|
||||
);
|
||||
|
||||
return {
|
||||
updatedEntries: Object.entries(result.keyMap).map(([key, citekey]) => {
|
||||
// Extract the bibtex for this citekey from the full bibtex string
|
||||
const bibtexEntries = result.bibtex.split(/\n(?=@)/);
|
||||
const entry = bibtexEntries.find((e) => extractCitekey(e) === citekey) ?? "";
|
||||
const entry =
|
||||
bibtexEntries.find((e) => extractCitekey(e) === citekey) ?? "";
|
||||
return { key, citekey, bibtex: entry };
|
||||
}),
|
||||
deletedKeys: [],
|
||||
|
|
@ -225,11 +238,16 @@ async function syncFullLibrary(
|
|||
limit: String(limit),
|
||||
start: String(start),
|
||||
});
|
||||
const response = await zoteroFetch(apiKey, `/users/${userID}/items/top?${params}`);
|
||||
const response = await zoteroFetch(
|
||||
apiKey,
|
||||
`/users/${userID}/items/top?${params}`,
|
||||
);
|
||||
|
||||
if (start === 0) {
|
||||
total = Number(response.headers.get("Total-Results") ?? 0);
|
||||
newVersion = Number(response.headers.get("Last-Modified-Version") ?? lastVersion);
|
||||
newVersion = Number(
|
||||
response.headers.get("Last-Modified-Version") ?? lastVersion,
|
||||
);
|
||||
}
|
||||
|
||||
const items = (await response.json()) as { key: string; bibtex?: string }[];
|
||||
|
|
@ -256,7 +274,9 @@ async function syncFullLibrary(
|
|||
const deletedKeys = deleted.items ?? [];
|
||||
|
||||
if (!newVersion || newVersion === lastVersion) {
|
||||
newVersion = Number(deletedResponse.headers.get("Last-Modified-Version") ?? lastVersion);
|
||||
newVersion = Number(
|
||||
deletedResponse.headers.get("Last-Modified-Version") ?? lastVersion,
|
||||
);
|
||||
}
|
||||
|
||||
return { updatedEntries, deletedKeys, libraryVersion: newVersion };
|
||||
|
|
|
|||
|
|
@ -59,7 +59,12 @@ export interface ClaudeStreamMessage {
|
|||
|
||||
export interface TabDraft {
|
||||
input: string;
|
||||
pinnedContexts: { label: string; filePath: string; selectedText: string; imageDataUrl?: string }[];
|
||||
pinnedContexts: {
|
||||
label: string;
|
||||
filePath: string;
|
||||
selectedText: string;
|
||||
imageDataUrl?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TabState {
|
||||
|
|
@ -149,9 +154,24 @@ interface ClaudeChatState {
|
|||
consumePendingInitialPrompt: () => string | null;
|
||||
|
||||
/** Pending attachments from external sources (e.g. PDF capture) */
|
||||
pendingAttachments: { label: string; filePath: string; selectedText: string; imageDataUrl?: string }[];
|
||||
addPendingAttachment: (attachment: { label: string; filePath: string; selectedText: string; imageDataUrl?: string }) => void;
|
||||
consumePendingAttachments: () => { label: string; filePath: string; selectedText: string; imageDataUrl?: string }[];
|
||||
pendingAttachments: {
|
||||
label: string;
|
||||
filePath: string;
|
||||
selectedText: string;
|
||||
imageDataUrl?: string;
|
||||
}[];
|
||||
addPendingAttachment: (attachment: {
|
||||
label: string;
|
||||
filePath: string;
|
||||
selectedText: string;
|
||||
imageDataUrl?: string;
|
||||
}) => void;
|
||||
consumePendingAttachments: () => {
|
||||
label: string;
|
||||
filePath: string;
|
||||
selectedText: string;
|
||||
imageDataUrl?: string;
|
||||
}[];
|
||||
|
||||
/** Currently selected model (passed per-prompt to Claude CLI) */
|
||||
selectedModel: "sonnet" | "opus" | "haiku" | "opusplan";
|
||||
|
|
@ -162,7 +182,10 @@ interface ClaudeChatState {
|
|||
setEffortLevel: (level: "low" | "medium" | "high") => void;
|
||||
|
||||
// Actions
|
||||
sendPrompt: (userPrompt: string, contextOverride?: { label: string; filePath: string; selectedText: string }) => Promise<void>;
|
||||
sendPrompt: (
|
||||
userPrompt: string,
|
||||
contextOverride?: { label: string; filePath: string; selectedText: string },
|
||||
) => Promise<void>;
|
||||
cancelExecution: () => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
newSession: () => void;
|
||||
|
|
@ -233,7 +256,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
|
||||
anyStreaming: () => get().tabs.some((t) => t.isStreaming),
|
||||
|
||||
sendPrompt: async (userPrompt: string, contextOverride?: { label: string; filePath: string; selectedText: string }) => {
|
||||
sendPrompt: async (
|
||||
userPrompt: string,
|
||||
contextOverride?: { label: string; filePath: string; selectedText: string },
|
||||
) => {
|
||||
const state = get();
|
||||
const { activeTabId } = state;
|
||||
const activeTab = state.tabs.find((t) => t.id === activeTabId);
|
||||
|
|
@ -243,7 +269,11 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
const { sessionId, selectedModel, effortLevel } = state;
|
||||
|
||||
const sendStart = performance.now();
|
||||
log.info("sendPrompt start", { sessionId: !!sessionId, hasContext: !!contextOverride, tab: activeTabId });
|
||||
log.info("sendPrompt start", {
|
||||
sessionId: !!sessionId,
|
||||
hasContext: !!contextOverride,
|
||||
tab: activeTabId,
|
||||
});
|
||||
|
||||
const docState = useDocumentStore.getState();
|
||||
const projectPath = docState.projectRoot;
|
||||
|
|
@ -253,7 +283,9 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
}
|
||||
|
||||
// Compute context label for display in chat history
|
||||
const activeFile = docState.files.find((f) => f.id === docState.activeFileId);
|
||||
const activeFile = docState.files.find(
|
||||
(f) => f.id === docState.activeFileId,
|
||||
);
|
||||
let contextLabel: string | null = null;
|
||||
|
||||
if (contextOverride) {
|
||||
|
|
@ -287,7 +319,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
|
||||
set((s) => {
|
||||
const tabUpdates: Partial<TabState> = {
|
||||
messages: [...(s.tabs.find((t) => t.id === activeTabId)?.messages ?? []), userMessage],
|
||||
messages: [
|
||||
...(s.tabs.find((t) => t.id === activeTabId)?.messages ?? []),
|
||||
userMessage,
|
||||
],
|
||||
isStreaming: true,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -309,9 +344,13 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
if (projectPath) {
|
||||
try {
|
||||
log.debug("creating snapshot...");
|
||||
await useHistoryStore.getState().createSnapshot(projectPath, "[claude] Before Claude edit");
|
||||
await useHistoryStore
|
||||
.getState()
|
||||
.createSnapshot(projectPath, "[claude] Before Claude edit");
|
||||
log.debug("snapshot done");
|
||||
} catch { /* snapshot failure should not block Claude */ }
|
||||
} catch {
|
||||
/* snapshot failure should not block Claude */
|
||||
}
|
||||
}
|
||||
|
||||
// Build prompt with full context for Claude
|
||||
|
|
@ -335,7 +374,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
}
|
||||
prompt = `${ctx}\n\n${userPrompt}`;
|
||||
}
|
||||
log.info("invoking CLI", { promptLength: prompt.length, mode: sessionId ? "resume" : "new" });
|
||||
log.info("invoking CLI", {
|
||||
promptLength: prompt.length,
|
||||
mode: sessionId ? "resume" : "new",
|
||||
});
|
||||
|
||||
try {
|
||||
if (sessionId) {
|
||||
|
|
@ -358,13 +400,20 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
effortLevel,
|
||||
});
|
||||
}
|
||||
log.info(`sendPrompt complete in ${(performance.now() - sendStart).toFixed(0)}ms`);
|
||||
log.info(
|
||||
`sendPrompt complete in ${(performance.now() - sendStart).toFixed(0)}ms`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
log.error(`sendPrompt failed after ${(performance.now() - sendStart).toFixed(0)}ms`, { error: String(err) });
|
||||
set((s) => applyTabUpdate(s, activeTabId, {
|
||||
isStreaming: false,
|
||||
error: err?.message || String(err),
|
||||
}));
|
||||
log.error(
|
||||
`sendPrompt failed after ${(performance.now() - sendStart).toFixed(0)}ms`,
|
||||
{ error: String(err) },
|
||||
);
|
||||
set((s) =>
|
||||
applyTabUpdate(s, activeTabId, {
|
||||
isStreaming: false,
|
||||
error: err?.message || String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -381,26 +430,30 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
|
||||
clearMessages: () => {
|
||||
const { activeTabId } = get();
|
||||
set((s) => applyTabUpdate(s, activeTabId, {
|
||||
messages: [],
|
||||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
}));
|
||||
set((s) =>
|
||||
applyTabUpdate(s, activeTabId, {
|
||||
messages: [],
|
||||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
newSession: () => {
|
||||
log.info("Starting new session");
|
||||
const { activeTabId } = get();
|
||||
set((s) => applyTabUpdate(s, activeTabId, {
|
||||
messages: [],
|
||||
sessionId: null,
|
||||
error: null,
|
||||
isStreaming: false,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
title: "New Chat",
|
||||
}));
|
||||
set((s) =>
|
||||
applyTabUpdate(s, activeTabId, {
|
||||
messages: [],
|
||||
sessionId: null,
|
||||
error: null,
|
||||
isStreaming: false,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
title: "New Chat",
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
resumeSession: async (sessionId: string) => {
|
||||
|
|
@ -409,14 +462,16 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
const projectPath = useDocumentStore.getState().projectRoot;
|
||||
|
||||
// Reset state with new session ID
|
||||
set((s) => applyTabUpdate(s, activeTabId, {
|
||||
messages: [],
|
||||
sessionId,
|
||||
error: null,
|
||||
isStreaming: false,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
}));
|
||||
set((s) =>
|
||||
applyTabUpdate(s, activeTabId, {
|
||||
messages: [],
|
||||
sessionId,
|
||||
error: null,
|
||||
isStreaming: false,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Load session history from JSONL file
|
||||
if (projectPath) {
|
||||
|
|
@ -515,7 +570,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
|
||||
saveDraft: (tabId: string, draft: TabDraft) => {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) => t.id === tabId ? { ...t, draft } : t),
|
||||
tabs: s.tabs.map((t) => (t.id === tabId ? { ...t, draft } : t)),
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -75,10 +75,19 @@ const LOGIN_STEPS: StepInfo[] = [
|
|||
{ id: "complete", label: "Authenticated", status: "pending" },
|
||||
];
|
||||
|
||||
const STEP_ORDER_INSTALL = ["downloading", "installing", "verifying", "complete"];
|
||||
const STEP_ORDER_INSTALL = [
|
||||
"downloading",
|
||||
"installing",
|
||||
"verifying",
|
||||
"complete",
|
||||
];
|
||||
const STEP_ORDER_LOGIN = ["opening-browser", "waiting-auth", "complete"];
|
||||
|
||||
function advanceSteps(steps: StepInfo[], targetId: string, order: string[]): StepInfo[] {
|
||||
function advanceSteps(
|
||||
steps: StepInfo[],
|
||||
targetId: string,
|
||||
order: string[],
|
||||
): StepInfo[] {
|
||||
const targetIdx = order.indexOf(targetId);
|
||||
return steps.map((s) => {
|
||||
const thisIdx = order.indexOf(s.id);
|
||||
|
|
@ -207,14 +216,18 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
|||
|
||||
_advanceInstallStep: (stepId: string) => {
|
||||
set((state) => ({
|
||||
installSteps: advanceSteps(state.installSteps, stepId, STEP_ORDER_INSTALL),
|
||||
installSteps: advanceSteps(
|
||||
state.installSteps,
|
||||
stepId,
|
||||
STEP_ORDER_INSTALL,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
_failCurrentStep: (error: string) => {
|
||||
set((state) => ({
|
||||
installSteps: state.installSteps.map((s) =>
|
||||
s.status === "active" ? { ...s, status: "error" as const } : s
|
||||
s.status === "active" ? { ...s, status: "error" as const } : s,
|
||||
),
|
||||
error,
|
||||
}));
|
||||
|
|
@ -229,7 +242,7 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
|||
_failCurrentLoginStep: (error: string) => {
|
||||
set((state) => ({
|
||||
loginSteps: state.loginSteps.map((s) =>
|
||||
s.status === "active" ? { ...s, status: "error" as const } : s
|
||||
s.status === "active" ? { ...s, status: "error" as const } : s,
|
||||
),
|
||||
error,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ export function getPdfBytes(rootFileId: string): Uint8Array | undefined {
|
|||
|
||||
/** Get the current active PDF bytes (convenience for components that don't know the rootId). */
|
||||
export function getCurrentPdfBytes(): Uint8Array | null {
|
||||
return _currentPdfRootId ? (_pdfBytesCache.get(_currentPdfRootId) ?? null) : null;
|
||||
return _currentPdfRootId
|
||||
? (_pdfBytesCache.get(_currentPdfRootId) ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Check if any PDF data exists for the current root. */
|
||||
|
|
@ -114,11 +116,21 @@ interface DocumentState {
|
|||
saveFile: (id: string) => Promise<void>;
|
||||
saveAllFiles: () => Promise<void>;
|
||||
saveCurrentFile: () => Promise<void>;
|
||||
createNewFile: (name: string, type: "tex" | "image", folder?: string) => Promise<void>;
|
||||
createNewFile: (
|
||||
name: string,
|
||||
type: "tex" | "image",
|
||||
folder?: string,
|
||||
) => Promise<void>;
|
||||
createFolder: (name: string, parentFolder?: string) => Promise<void>;
|
||||
importFiles: (sourcePaths: string[], targetFolder?: string) => Promise<string[]>;
|
||||
importFiles: (
|
||||
sourcePaths: string[],
|
||||
targetFolder?: string,
|
||||
) => Promise<string[]>;
|
||||
moveFile: (fileId: string, targetFolder: string | null) => Promise<void>;
|
||||
moveFolder: (folderPath: string, targetFolder: string | null) => Promise<void>;
|
||||
moveFolder: (
|
||||
folderPath: string,
|
||||
targetFolder: string | null,
|
||||
) => Promise<void>;
|
||||
reloadFile: (relativePath: string) => Promise<void>;
|
||||
refreshFiles: () => Promise<void>;
|
||||
/** Load content for a file that was skipped during project open (large file). */
|
||||
|
|
@ -149,7 +161,9 @@ export function resolveTexRoot(fileId: string, files: ProjectFile[]): string {
|
|||
if (match) {
|
||||
const rootPath = match[1].trim();
|
||||
// Try matching by relative path first, then by filename
|
||||
const target = files.find((f) => f.relativePath === rootPath) ?? files.find((f) => f.name === rootPath);
|
||||
const target =
|
||||
files.find((f) => f.relativePath === rootPath) ??
|
||||
files.find((f) => f.name === rootPath);
|
||||
if (target) return target.id;
|
||||
}
|
||||
}
|
||||
|
|
@ -166,7 +180,11 @@ function migratePdfBytesKey(oldKey: string, newKey: string) {
|
|||
}
|
||||
|
||||
/** Re-key a Map entry when a file is renamed/moved. */
|
||||
function migrateCacheKey<V>(map: Map<string, V>, oldKey: string, newKey: string): Map<string, V> {
|
||||
function migrateCacheKey<V>(
|
||||
map: Map<string, V>,
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
): Map<string, V> {
|
||||
if (!map.has(oldKey)) return map;
|
||||
const copy = new Map(map);
|
||||
const val = copy.get(oldKey)!;
|
||||
|
|
@ -186,7 +204,9 @@ function scheduleAutoSave() {
|
|||
const store = storeRef;
|
||||
if (!store) return;
|
||||
const state = store.getState();
|
||||
const dirtyFiles = state.files.filter((f) => f.isDirty && f.content != null);
|
||||
const dirtyFiles = state.files.filter(
|
||||
(f) => f.isDirty && f.content != null,
|
||||
);
|
||||
if (dirtyFiles.length > 0) {
|
||||
await state.saveAllFiles();
|
||||
}
|
||||
|
|
@ -214,7 +234,8 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
|
||||
openProject: async (rootPath: string) => {
|
||||
log.info(`Opening project: ${rootPath}`);
|
||||
const { files: fsFiles, folders: fsFolders } = await scanProjectFolder(rootPath);
|
||||
const { files: fsFiles, folders: fsFolders } =
|
||||
await scanProjectFolder(rootPath);
|
||||
const projectFiles: ProjectFile[] = [];
|
||||
|
||||
for (const f of fsFiles) {
|
||||
|
|
@ -229,8 +250,14 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
};
|
||||
|
||||
// Load content for text-based files (skip large non-essential files)
|
||||
if (f.type === "tex" || f.type === "bib" || f.type === "style" || f.type === "other") {
|
||||
const isLargeNonEssential = f.type === "other" && f.fileSize > LARGE_FILE_THRESHOLD;
|
||||
if (
|
||||
f.type === "tex" ||
|
||||
f.type === "bib" ||
|
||||
f.type === "style" ||
|
||||
f.type === "other"
|
||||
) {
|
||||
const isLargeNonEssential =
|
||||
f.type === "other" && f.fileSize > LARGE_FILE_THRESHOLD;
|
||||
if (!isLargeNonEssential) {
|
||||
try {
|
||||
pf.content = await readTexFileContent(f.absolutePath);
|
||||
|
|
@ -259,8 +286,9 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
|
||||
// Find the main tex file
|
||||
const mainTex =
|
||||
projectFiles.find((f) => f.name === "main.tex" || f.name === "document.tex") ||
|
||||
projectFiles.find((f) => f.type === "tex");
|
||||
projectFiles.find(
|
||||
(f) => f.name === "main.tex" || f.name === "document.tex",
|
||||
) || projectFiles.find((f) => f.type === "tex");
|
||||
|
||||
clearPdfBytesCache();
|
||||
set({
|
||||
|
|
@ -364,7 +392,9 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
lastCompiledGenerations.delete(id);
|
||||
// If the deleted file was active, show the new active file's cached PDF
|
||||
const switchingActive = state.activeFileId === id;
|
||||
const newRootId = switchingActive ? resolveTexRoot(newActiveId, newFiles) : undefined;
|
||||
const newRootId = switchingActive
|
||||
? resolveTexRoot(newActiveId, newFiles)
|
||||
: undefined;
|
||||
if (switchingActive && newRootId) {
|
||||
_currentPdfRootId = _pdfBytesCache.has(newRootId) ? newRootId : null;
|
||||
}
|
||||
|
|
@ -374,18 +404,20 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
compileErrorCache,
|
||||
lastCompiledGenerations,
|
||||
...(switchingActive ? { pdfRevision: s.pdfRevision + 1 } : {}),
|
||||
...(switchingActive && newRootId ? {
|
||||
compileError: compileErrorCache.get(newRootId) ?? null,
|
||||
} : {}),
|
||||
...(switchingActive && newRootId
|
||||
? {
|
||||
compileError: compileErrorCache.get(newRootId) ?? null,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
},
|
||||
|
||||
deleteFolder: async (folderPath) => {
|
||||
const state = get();
|
||||
if (!state.projectRoot) return;
|
||||
const prefix = folderPath + "/";
|
||||
const filesToRemove = state.files.filter(
|
||||
(f) => f.relativePath.startsWith(prefix),
|
||||
const prefix = `${folderPath}/`;
|
||||
const filesToRemove = state.files.filter((f) =>
|
||||
f.relativePath.startsWith(prefix),
|
||||
);
|
||||
const remainingFiles = state.files.filter(
|
||||
(f) => !f.relativePath.startsWith(prefix),
|
||||
|
|
@ -415,7 +447,9 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
? remainingFiles[0].id
|
||||
: state.activeFileId;
|
||||
const switchingActive = newActiveId !== state.activeFileId;
|
||||
const newRootId = switchingActive ? resolveTexRoot(newActiveId, remainingFiles) : undefined;
|
||||
const newRootId = switchingActive
|
||||
? resolveTexRoot(newActiveId, remainingFiles)
|
||||
: undefined;
|
||||
if (switchingActive && newRootId) {
|
||||
_currentPdfRootId = _pdfBytesCache.has(newRootId) ? newRootId : null;
|
||||
}
|
||||
|
|
@ -432,9 +466,11 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
compileErrorCache,
|
||||
lastCompiledGenerations,
|
||||
...(switchingActive ? { pdfRevision: s.pdfRevision + 1 } : {}),
|
||||
...(switchingActive && newRootId ? {
|
||||
compileError: compileErrorCache.get(newRootId) ?? null,
|
||||
} : {}),
|
||||
...(switchingActive && newRootId
|
||||
? {
|
||||
compileError: compileErrorCache.get(newRootId) ?? null,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
@ -457,8 +493,16 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
}
|
||||
migratePdfBytesKey(id, newRelativePath);
|
||||
set((s) => {
|
||||
const compileErrorCache = migrateCacheKey(s.compileErrorCache, id, newRelativePath);
|
||||
const lastCompiledGenerations = migrateCacheKey(s.lastCompiledGenerations, id, newRelativePath);
|
||||
const compileErrorCache = migrateCacheKey(
|
||||
s.compileErrorCache,
|
||||
id,
|
||||
newRelativePath,
|
||||
);
|
||||
const lastCompiledGenerations = migrateCacheKey(
|
||||
s.lastCompiledGenerations,
|
||||
id,
|
||||
newRelativePath,
|
||||
);
|
||||
const isActive = s.activeFileId === id;
|
||||
return {
|
||||
files: s.files.map((f) =>
|
||||
|
|
@ -491,9 +535,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
|
||||
updateImageDataUrl: (id, dataUrl) => {
|
||||
set((state) => ({
|
||||
files: state.files.map((f) =>
|
||||
f.id === id ? { ...f, dataUrl } : f,
|
||||
),
|
||||
files: state.files.map((f) => (f.id === id ? { ...f, dataUrl } : f)),
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
@ -524,10 +566,16 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
lastCompiledGenerations,
|
||||
}));
|
||||
} else {
|
||||
set((prev) => ({ pdfRevision: prev.pdfRevision + 1, compileError: null }));
|
||||
set((prev) => ({
|
||||
pdfRevision: prev.pdfRevision + 1,
|
||||
compileError: null,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
set((prev) => ({ pdfRevision: prev.pdfRevision + 1, compileError: null }));
|
||||
set((prev) => ({
|
||||
pdfRevision: prev.pdfRevision + 1,
|
||||
compileError: null,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -555,7 +603,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
insertAtCursor: (text) => {
|
||||
const state = get();
|
||||
const activeFile = getActiveFile(state);
|
||||
if (!activeFile || (activeFile.type === "image" || activeFile.type === "pdf"))
|
||||
if (!activeFile || activeFile.type === "image" || activeFile.type === "pdf")
|
||||
return;
|
||||
|
||||
const content = activeFile.content ?? "";
|
||||
|
|
@ -576,7 +624,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
replaceSelection: (start, end, text) => {
|
||||
const state = get();
|
||||
const activeFile = getActiveFile(state);
|
||||
if (!activeFile || (activeFile.type === "image" || activeFile.type === "pdf"))
|
||||
if (!activeFile || activeFile.type === "image" || activeFile.type === "pdf")
|
||||
return;
|
||||
|
||||
const content = activeFile.content ?? "";
|
||||
|
|
@ -595,7 +643,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
findAndReplace: (find, replace) => {
|
||||
const state = get();
|
||||
const activeFile = getActiveFile(state);
|
||||
if (!activeFile || (activeFile.type === "image" || activeFile.type === "pdf"))
|
||||
if (!activeFile || activeFile.type === "image" || activeFile.type === "pdf")
|
||||
return false;
|
||||
|
||||
const content = activeFile.content ?? "";
|
||||
|
|
@ -621,15 +669,15 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
|
||||
await writeTexFileContent(file.absolutePath, file.content);
|
||||
set((s) => ({
|
||||
files: s.files.map((f) =>
|
||||
f.id === id ? { ...f, isDirty: false } : f,
|
||||
),
|
||||
files: s.files.map((f) => (f.id === id ? { ...f, isDirty: false } : f)),
|
||||
}));
|
||||
},
|
||||
|
||||
saveAllFiles: async () => {
|
||||
const state = get();
|
||||
const dirtyFiles = state.files.filter((f) => f.isDirty && f.content != null);
|
||||
const dirtyFiles = state.files.filter(
|
||||
(f) => f.isDirty && f.content != null,
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
dirtyFiles.map((f) => writeTexFileContent(f.absolutePath, f.content!)),
|
||||
);
|
||||
|
|
@ -653,7 +701,9 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
// Manual save → immediate snapshot
|
||||
if (state.projectRoot) {
|
||||
try {
|
||||
await useHistoryStore.getState().createSnapshot(state.projectRoot, "[manual] Save");
|
||||
await useHistoryStore
|
||||
.getState()
|
||||
.createSnapshot(state.projectRoot, "[manual] Save");
|
||||
} catch {
|
||||
// Snapshot failure should not break save
|
||||
}
|
||||
|
|
@ -670,7 +720,11 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
? `\\documentclass{article}\n\n\\begin{document}\n\n% Your content here\n\n\\end{document}\n`
|
||||
: "";
|
||||
|
||||
const fullPath = await createFileOnDisk(state.projectRoot, relativePath, content);
|
||||
const fullPath = await createFileOnDisk(
|
||||
state.projectRoot,
|
||||
relativePath,
|
||||
content,
|
||||
);
|
||||
|
||||
set((s) => ({
|
||||
files: [
|
||||
|
|
@ -709,9 +763,15 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
for (const sourcePath of sourcePaths) {
|
||||
// Handle both Unix (/) and Windows (\) path separators
|
||||
const fileName = sourcePath.split(/[/\\]/).pop() || sourcePath;
|
||||
const targetName = targetFolder ? `${targetFolder}/${fileName}` : fileName;
|
||||
const targetName = targetFolder
|
||||
? `${targetFolder}/${fileName}`
|
||||
: fileName;
|
||||
// copyFileToProject returns the actual (possibly deduplicated) relative path
|
||||
const actualName = await copyFileToProject(state.projectRoot, sourcePath, targetName);
|
||||
const actualName = await copyFileToProject(
|
||||
state.projectRoot,
|
||||
sourcePath,
|
||||
targetName,
|
||||
);
|
||||
importedPaths.push(actualName);
|
||||
}
|
||||
await state.refreshFiles();
|
||||
|
|
@ -723,26 +783,46 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
const file = state.files.find((f) => f.id === fileId);
|
||||
if (!file || !state.projectRoot) return;
|
||||
|
||||
const desiredPath = targetFolder ? `${targetFolder}/${file.name}` : file.name;
|
||||
const desiredPath = targetFolder
|
||||
? `${targetFolder}/${file.name}`
|
||||
: file.name;
|
||||
if (desiredPath === file.relativePath) return;
|
||||
|
||||
// Auto-deduplicate if a file with the same name exists in the target
|
||||
const newRelativePath = await getUniqueTargetName(state.projectRoot, desiredPath);
|
||||
const newRelativePath = await getUniqueTargetName(
|
||||
state.projectRoot,
|
||||
desiredPath,
|
||||
);
|
||||
const newAbsPath = await join(state.projectRoot, newRelativePath);
|
||||
await renameFileOnDisk(file.absolutePath, newAbsPath);
|
||||
|
||||
const newName = newRelativePath.split("/").pop() || file.name;
|
||||
migratePdfBytesKey(fileId, newRelativePath);
|
||||
set((s) => {
|
||||
const compileErrorCache = migrateCacheKey(s.compileErrorCache, fileId, newRelativePath);
|
||||
const lastCompiledGenerations = migrateCacheKey(s.lastCompiledGenerations, fileId, newRelativePath);
|
||||
const compileErrorCache = migrateCacheKey(
|
||||
s.compileErrorCache,
|
||||
fileId,
|
||||
newRelativePath,
|
||||
);
|
||||
const lastCompiledGenerations = migrateCacheKey(
|
||||
s.lastCompiledGenerations,
|
||||
fileId,
|
||||
newRelativePath,
|
||||
);
|
||||
return {
|
||||
files: s.files.map((f) =>
|
||||
f.id === fileId
|
||||
? { ...f, name: newName, relativePath: newRelativePath, absolutePath: newAbsPath, id: newRelativePath }
|
||||
? {
|
||||
...f,
|
||||
name: newName,
|
||||
relativePath: newRelativePath,
|
||||
absolutePath: newAbsPath,
|
||||
id: newRelativePath,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
activeFileId: s.activeFileId === fileId ? newRelativePath : s.activeFileId,
|
||||
activeFileId:
|
||||
s.activeFileId === fileId ? newRelativePath : s.activeFileId,
|
||||
compileErrorCache,
|
||||
lastCompiledGenerations,
|
||||
};
|
||||
|
|
@ -754,10 +834,12 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
if (!state.projectRoot) return;
|
||||
|
||||
const folderName = folderPath.split("/").pop()!;
|
||||
const newFolderPath = targetFolder ? `${targetFolder}/${folderName}` : folderName;
|
||||
const newFolderPath = targetFolder
|
||||
? `${targetFolder}/${folderName}`
|
||||
: folderName;
|
||||
if (newFolderPath === folderPath) return;
|
||||
// Prevent moving a folder into itself
|
||||
if (newFolderPath.startsWith(folderPath + "/")) return;
|
||||
if (newFolderPath.startsWith(`${folderPath}/`)) return;
|
||||
|
||||
const oldAbsPath = await join(state.projectRoot, folderPath);
|
||||
const newAbsPath = await join(state.projectRoot, newFolderPath);
|
||||
|
|
@ -787,7 +869,8 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
const { projectRoot, files, activeFileId } = get();
|
||||
if (!projectRoot) return;
|
||||
|
||||
const { files: fsFiles, folders: fsFolders } = await scanProjectFolder(projectRoot);
|
||||
const { files: fsFiles, folders: fsFolders } =
|
||||
await scanProjectFolder(projectRoot);
|
||||
const existingMap = new Map(files.map((f) => [f.relativePath, f]));
|
||||
const diskPaths = new Set(fsFiles.map((f) => f.relativePath));
|
||||
|
||||
|
|
@ -802,13 +885,24 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
merged.push(existing);
|
||||
} else {
|
||||
const updated = { ...existing, fileSize: fsFile.fileSize };
|
||||
if (updated.type === "tex" || updated.type === "bib" || updated.type === "style" || updated.type === "other") {
|
||||
const isLargeNonEssential = updated.type === "other" && fsFile.fileSize > LARGE_FILE_THRESHOLD;
|
||||
if (
|
||||
updated.type === "tex" ||
|
||||
updated.type === "bib" ||
|
||||
updated.type === "style" ||
|
||||
updated.type === "other"
|
||||
) {
|
||||
const isLargeNonEssential =
|
||||
updated.type === "other" &&
|
||||
fsFile.fileSize > LARGE_FILE_THRESHOLD;
|
||||
// Only reload if it was previously loaded (not a skipped large file)
|
||||
if (!isLargeNonEssential || updated.content !== undefined) {
|
||||
try {
|
||||
updated.content = await readTexFileContent(updated.absolutePath);
|
||||
} catch { /* keep previous content */ }
|
||||
updated.content = await readTexFileContent(
|
||||
updated.absolutePath,
|
||||
);
|
||||
} catch {
|
||||
/* keep previous content */
|
||||
}
|
||||
}
|
||||
}
|
||||
merged.push(updated);
|
||||
|
|
@ -824,15 +918,28 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
isDirty: false,
|
||||
fileSize: fsFile.fileSize,
|
||||
};
|
||||
const isLargeNonEssential = pf.type === "other" && fsFile.fileSize > LARGE_FILE_THRESHOLD;
|
||||
if (pf.type === "tex" || pf.type === "bib" || pf.type === "style" || (pf.type === "other" && !isLargeNonEssential)) {
|
||||
const isLargeNonEssential =
|
||||
pf.type === "other" && fsFile.fileSize > LARGE_FILE_THRESHOLD;
|
||||
if (
|
||||
pf.type === "tex" ||
|
||||
pf.type === "bib" ||
|
||||
pf.type === "style" ||
|
||||
(pf.type === "other" && !isLargeNonEssential)
|
||||
) {
|
||||
try {
|
||||
pf.content = await readTexFileContent(pf.absolutePath);
|
||||
} catch { /* skip unreadable */ }
|
||||
} else if (pf.type === "image" && fsFile.fileSize <= LARGE_FILE_THRESHOLD) {
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
} else if (
|
||||
pf.type === "image" &&
|
||||
fsFile.fileSize <= LARGE_FILE_THRESHOLD
|
||||
) {
|
||||
try {
|
||||
pf.dataUrl = await readImageAsDataUrl(pf.absolutePath);
|
||||
} catch { /* skip unreadable */ }
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
// PDF files and large files are loaded on-demand
|
||||
merged.push(pf);
|
||||
|
|
@ -848,7 +955,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
|
||||
const newActiveId = merged.some((f) => f.id === activeFileId)
|
||||
? activeFileId
|
||||
: merged[0]?.id ?? "";
|
||||
: (merged[0]?.id ?? "");
|
||||
|
||||
set((s) => ({
|
||||
files: merged,
|
||||
|
|
@ -897,9 +1004,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
|||
const state = get();
|
||||
set({
|
||||
files: state.files.map((f) =>
|
||||
f.id === state.activeFileId
|
||||
? { ...f, content, isDirty: true }
|
||||
: f,
|
||||
f.id === state.activeFileId ? { ...f, content, isDirty: true } : f,
|
||||
),
|
||||
contentGeneration: state.contentGeneration + 1,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,14 +31,32 @@ interface HistoryState {
|
|||
reviewingSnapshot: SnapshotInfo | null;
|
||||
|
||||
init: (projectRoot: string) => Promise<void>;
|
||||
createSnapshot: (projectRoot: string, message: string) => Promise<SnapshotInfo | null>;
|
||||
createSnapshot: (
|
||||
projectRoot: string,
|
||||
message: string,
|
||||
) => Promise<SnapshotInfo | null>;
|
||||
loadSnapshots: (projectRoot: string) => Promise<void>;
|
||||
loadMoreSnapshots: (projectRoot: string) => Promise<void>;
|
||||
selectSnapshot: (id: string | null) => void;
|
||||
loadDiff: (projectRoot: string, fromId: string, toId: string) => Promise<void>;
|
||||
getFileAt: (projectRoot: string, snapshotId: string, filePath: string) => Promise<string>;
|
||||
restoreSnapshot: (projectRoot: string, snapshotId: string) => Promise<SnapshotInfo>;
|
||||
addLabel: (projectRoot: string, snapshotId: string, label: string) => Promise<void>;
|
||||
loadDiff: (
|
||||
projectRoot: string,
|
||||
fromId: string,
|
||||
toId: string,
|
||||
) => Promise<void>;
|
||||
getFileAt: (
|
||||
projectRoot: string,
|
||||
snapshotId: string,
|
||||
filePath: string,
|
||||
) => Promise<string>;
|
||||
restoreSnapshot: (
|
||||
projectRoot: string,
|
||||
snapshotId: string,
|
||||
) => Promise<SnapshotInfo>;
|
||||
addLabel: (
|
||||
projectRoot: string,
|
||||
snapshotId: string,
|
||||
label: string,
|
||||
) => Promise<void>;
|
||||
removeLabel: (projectRoot: string, label: string) => Promise<void>;
|
||||
startReview: (snapshot: SnapshotInfo) => void;
|
||||
stopReview: () => void;
|
||||
|
|
@ -65,7 +83,7 @@ export const useHistoryStore = create<HistoryState>()((set, get) => ({
|
|||
},
|
||||
|
||||
init: async (projectRoot) => {
|
||||
log.debug("Initializing history for " + projectRoot);
|
||||
log.debug(`Initializing history for ${projectRoot}`);
|
||||
await invoke("history_init", { projectRoot });
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@ interface ProposedChangesState {
|
|||
changes: ProposedChange[];
|
||||
|
||||
// Actions
|
||||
addChange: (
|
||||
change: Omit<ProposedChange, "timestamp">
|
||||
) => void;
|
||||
addChange: (change: Omit<ProposedChange, "timestamp">) => void;
|
||||
resolveChange: (id: string) => void;
|
||||
keepChange: (id: string) => void;
|
||||
undoChange: (id: string) => Promise<void>;
|
||||
|
|
@ -54,10 +52,7 @@ export const useProposedChangesStore = create<ProposedChangesState>()(
|
|||
return { changes: newChanges };
|
||||
}
|
||||
return {
|
||||
changes: [
|
||||
...state.changes,
|
||||
{ ...change, timestamp: Date.now() },
|
||||
],
|
||||
changes: [...state.changes, { ...change, timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -79,8 +74,8 @@ export const useProposedChangesStore = create<ProposedChangesState>()(
|
|||
.getState()
|
||||
.files.find((f) => f.relativePath === change.filePath);
|
||||
if (file?.content != null) {
|
||||
writeTexFileContent(change.absolutePath, file.content).catch(
|
||||
(err) => log.error("Failed to write kept change", { error: String(err) }),
|
||||
writeTexFileContent(change.absolutePath, file.content).catch((err) =>
|
||||
log.error("Failed to write kept change", { error: String(err) }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -128,5 +123,5 @@ export const useProposedChangesStore = create<ProposedChangesState>()(
|
|||
getChangeForFile: (relativePath) => {
|
||||
return get().changes.find((c) => c.filePath === relativePath);
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
type TemplateDefinition,
|
||||
getAllTemplates,
|
||||
searchTemplates,
|
||||
getTemplatesByCategory,
|
||||
} from "@/lib/template-registry";
|
||||
|
||||
interface TemplateState {
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ export const useUvSetupStore = create<UvSetupState>((set, get) => ({
|
|||
set({
|
||||
isInstalling: false,
|
||||
status: "error",
|
||||
error: "uv installation failed. Check your internet connection and try again.",
|
||||
error:
|
||||
"uv installation failed. Check your internet connection and try again.",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ export interface CollectionSyncInfo {
|
|||
}
|
||||
|
||||
/** Synced collections scoped per project path */
|
||||
type ProjectSyncedCollections = Record<string, Record<string, CollectionSyncInfo>>;
|
||||
type ProjectSyncedCollections = Record<
|
||||
string,
|
||||
Record<string, CollectionSyncInfo>
|
||||
>;
|
||||
|
||||
interface ZoteroState {
|
||||
// Persisted
|
||||
|
|
@ -51,7 +54,10 @@ interface ZoteroState {
|
|||
disconnect: () => void;
|
||||
revalidate: () => Promise<void>;
|
||||
loadCollections: () => Promise<void>;
|
||||
importCollectionToBib: (collectionKey: string | null, name: string) => Promise<void>;
|
||||
importCollectionToBib: (
|
||||
collectionKey: string | null,
|
||||
name: string,
|
||||
) => Promise<void>;
|
||||
syncCollectionBib: (collectionKey: string | null) => Promise<void>;
|
||||
removeCollection: (collectionKey: string | null) => void;
|
||||
}
|
||||
|
|
@ -62,7 +68,10 @@ function storeKey(collectionKey: string | null): string {
|
|||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_\-\s]/g, "").replace(/\s+/g, "-").toLowerCase();
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9_\-\s]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Parse a .bib file into a map of citekey → full entry string */
|
||||
|
|
@ -205,15 +214,22 @@ export const useZoteroStore = create<ZoteroState>()(
|
|||
set({ isSyncing: sk, syncProgress: null, error: null });
|
||||
|
||||
try {
|
||||
const result = await importCollection(apiKey, userID, collectionKey, (loaded, total) => {
|
||||
set({ syncProgress: { loaded, total } });
|
||||
});
|
||||
const result = await importCollection(
|
||||
apiKey,
|
||||
userID,
|
||||
collectionKey,
|
||||
(loaded, total) => {
|
||||
set({ syncProgress: { loaded, total } });
|
||||
},
|
||||
);
|
||||
|
||||
// Determine .bib file name
|
||||
const bibFileName = `${sanitizeFileName(name)}.bib`;
|
||||
|
||||
// Check if this .bib file already exists in the project
|
||||
const existingFile = docStore.files.find((f) => f.name === bibFileName);
|
||||
const existingFile = docStore.files.find(
|
||||
(f) => f.name === bibFileName,
|
||||
);
|
||||
if (existingFile) {
|
||||
docStore.updateFileContent(existingFile.id, result.bibtex);
|
||||
} else {
|
||||
|
|
@ -272,15 +288,22 @@ export const useZoteroStore = create<ZoteroState>()(
|
|||
const syncInfo = projectColls[sk];
|
||||
if (!syncInfo) return;
|
||||
|
||||
const bibFile = docStore.files.find((f) => f.name === syncInfo.bibFileName);
|
||||
const bibFile = docStore.files.find(
|
||||
(f) => f.name === syncInfo.bibFileName,
|
||||
);
|
||||
if (!bibFile) return;
|
||||
|
||||
set({ isSyncing: sk, syncProgress: null, error: null });
|
||||
|
||||
try {
|
||||
const result = await syncCollection(
|
||||
apiKey, userID, collectionKey, syncInfo.libraryVersion,
|
||||
(loaded, total) => { set({ syncProgress: { loaded, total } }); },
|
||||
apiKey,
|
||||
userID,
|
||||
collectionKey,
|
||||
syncInfo.libraryVersion,
|
||||
(loaded, total) => {
|
||||
set({ syncProgress: { loaded, total } });
|
||||
},
|
||||
);
|
||||
|
||||
if (collectionKey) {
|
||||
|
|
@ -294,7 +317,7 @@ export const useZoteroStore = create<ZoteroState>()(
|
|||
newKeyMap[entry.key] = entry.citekey;
|
||||
}
|
||||
}
|
||||
const updatedContent = entries.join("\n\n") + "\n";
|
||||
const updatedContent = `${entries.join("\n\n")}\n`;
|
||||
docStore.updateFileContent(bibFile.id, updatedContent);
|
||||
|
||||
set((s) => {
|
||||
|
|
@ -338,7 +361,7 @@ export const useZoteroStore = create<ZoteroState>()(
|
|||
}
|
||||
}
|
||||
|
||||
const updatedContent = Array.from(entries.values()).join("\n\n") + "\n";
|
||||
const updatedContent = `${Array.from(entries.values()).join("\n\n")}\n`;
|
||||
docStore.updateFileContent(bibFile.id, updatedContent);
|
||||
|
||||
set((s) => {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit c3d8dbe5163603bc80a842145697e6bfe24d7a05
|
||||
Subproject commit 43d9b03108d84a31e36fd0b71f93e442521e0883
|
||||
7640
pnpm-lock.yaml
generated
7640
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
14
turbo.json
14
turbo.json
|
|
@ -5,12 +5,14 @@
|
|||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"outputs": [".next/**", "!.next/cache/**", ".wrangler/**", "dist/**", "src-tauri/target/**"],
|
||||
"env": [
|
||||
"LATEX_API_URL",
|
||||
"KV_REST_API_URL",
|
||||
"KV_REST_API_TOKEN"
|
||||
]
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**",
|
||||
".wrangler/**",
|
||||
"dist/**",
|
||||
"src-tauri/target/**"
|
||||
],
|
||||
"env": ["LATEX_API_URL", "KV_REST_API_URL", "KV_REST_API_TOKEN"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue