diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx index 39d264f..e387fcb 100644 --- a/apps/desktop/src/components/project-picker.tsx +++ b/apps/desktop/src/components/project-picker.tsx @@ -1,13 +1,20 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ComponentType, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; +import { readFile, readTextFile, stat } from "@tauri-apps/plugin-fs"; import { toast } from "sonner"; import { FolderOpenIcon, - FolderPlusIcon, - ClockIcon, XIcon, FileTextIcon, SparklesIcon, @@ -18,17 +25,27 @@ import { RefreshCwIcon, ArrowUpCircleIcon, KeyRoundIcon, + SearchIcon, + PanelLeftIcon, + PlusIcon, + SettingsIcon, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { useProjectStore } from "@/stores/project-store"; import { useDocumentStore } from "@/stores/document-store"; import { useClaudeSetupStore } from "@/stores/claude-setup-store"; import { useUvSetupStore } from "@/stores/uv-setup-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useUpdater } from "@/hooks/use-updater"; +import { compileLatex } from "@/lib/latex-compiler"; +import { getMupdfClient } from "@/lib/mupdf/mupdf-client"; +import { exists, join } from "@/lib/tauri/fs"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, + DialogFooter, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; @@ -43,13 +60,46 @@ interface DefaultProject { has_main_tex: boolean; } +type ProjectPickerSection = "projects" | "settings"; + +type RecentProject = { + path: string; + name: string; + lastOpened: number; +}; + +type ProjectPreviewData = { + createdAt: number | null; +} & ( + | { kind: "pdf"; url: string } + | { kind: "tex"; fileName: string; lines: string[] } + | { kind: "empty" } +); + +type ProjectPreviewState = + | { status: "loading" } + | { status: "ready"; data: ProjectPreviewData } + | { status: "error" }; + +const projectPreviewCache = new Map(); +const projectPreviewRequests = new Map>(); +let projectPreviewCompileQueue: Promise = Promise.resolve(); + export function ProjectPicker() { const [showModeDialog, setShowModeDialog] = useState(false); const [wizardMode, setWizardMode] = useState(null); const [appVersion, setAppVersion] = useState(""); const [isRestoringProject, setIsRestoringProject] = useState(false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [activeSection, setActiveSection] = + useState("projects"); + const [searchQuery, setSearchQuery] = useState(""); + const [removeProjectTarget, setRemoveProjectTarget] = + useState(null); const recoveryAttemptedRef = useRef(false); + const searchInputRef = useRef(null); const { status: updateStatus, checkForUpdate, installUpdate } = useUpdater(); + const searchShortcutLabel = "⌘ K"; const recentProjects = useProjectStore((s) => s.recentProjects); const initialRecentProjectsRef = useRef(recentProjects); @@ -69,6 +119,29 @@ export function ProjectPicker() { getVersion().then(setAppVersion); }, [checkClaudeStatus]); + useEffect(() => { + const handleSearchShortcut = (event: KeyboardEvent) => { + if ( + event.key.toLowerCase() !== "k" || + event.altKey || + event.shiftKey || + (!event.metaKey && !event.ctrlKey) + ) { + return; + } + + event.preventDefault(); + setActiveSection("projects"); + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + }; + + window.addEventListener("keydown", handleSearchShortcut); + return () => window.removeEventListener("keydown", handleSearchShortcut); + }, []); + useEffect(() => { if (recoveryAttemptedRef.current) return; recoveryAttemptedRef.current = true; @@ -168,6 +241,16 @@ export function ProjectPicker() { setWizardMode(mode); }; + const normalizedSearch = searchQuery.trim().toLowerCase(); + const visibleProjects = useMemo(() => { + if (!normalizedSearch) return recentProjects; + return recentProjects.filter( + (project) => + project.name.toLowerCase().includes(normalizedSearch) || + project.path.toLowerCase().includes(normalizedSearch), + ); + }, [normalizedSearch, recentProjects]); + if (wizardMode) { return ( setWizardMode(null)} /> @@ -175,93 +258,184 @@ export function ProjectPicker() { } return ( -
-
-
- ClaudePrism -

ClaudePrism

- -

- AI-powered academic writing workspace -

-
- - {!isClaudeReady ? : } - - {isRestoringProject && ( -
- - Restoring last project... -
+
+ + +
+
+
+

+ {activeSection === "settings" ? "Settings" : "All Projects"} +

+
+ + {activeSection === "projects" && ( +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search" + className="h-9 w-full rounded-lg border border-input bg-background pr-16 pl-9 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring" + /> + + {searchShortcutLabel} + +
+ + +
-
- {recentProjects.map((project) => ( -
- - + )} +
+ +
+ {activeSection === "settings" ? ( +
+
+

Application

+
+
- ))} +
+
+

Environment

+ {!isClaudeReady ? : } +
-
- )} -
+ ) : ( +
+ {isRestoringProject && ( +
+ + Restoring last project... +
+ )} + + {visibleProjects.length === 0 ? ( +
+ +

+ {normalizedSearch ? "No matching projects" : "No projects"} +

+
+ + +
+
+ ) : ( +
+ {visibleProjects.map((project) => ( + handleOpenRecent(project.path)} + onRemove={() => setRemoveProjectTarget(project)} + /> + ))} +
+ )} +
+ )} +
+ {/* New Project mode selection dialog */} @@ -273,10 +447,10 @@ export function ProjectPicker() {
+ + { + if (!open) setRemoveProjectTarget(null); + }} + > + + + Remove Project + + Remove "{removeProjectTarget?.name ?? "this project"}" from All + Projects? The project files will stay on disk. + + + + + + + +
); } @@ -318,6 +528,368 @@ interface SkillsStatus { location: string; } +function projectPreviewCacheKey(project: RecentProject) { + return `${project.path}:${project.lastOpened}`; +} + +function enqueueProjectPreviewCompile(task: () => Promise): Promise { + const run = projectPreviewCompileQueue.then(task, task); + projectPreviewCompileQueue = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +async function firstExistingProjectFile( + projectPath: string, + candidates: string[][], +): Promise<{ absolutePath: string; relativePath: string } | null> { + for (const segments of candidates) { + const absolutePath = await join(projectPath, ...segments); + if (await exists(absolutePath)) { + return { + absolutePath, + relativePath: segments.join("/"), + }; + } + } + return null; +} + +async function firstExistingPath( + projectPath: string, + candidates: string[][], +): Promise { + return ( + (await firstExistingProjectFile(projectPath, candidates))?.absolutePath ?? + null + ); +} + +async function renderPdfThumbnailFromBytes(bytes: Uint8Array): Promise { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const client = getMupdfClient(); + let docId: number | null = null; + + try { + docId = await client.openDocument(buffer); + const pngBuffer = await client.renderThumbnail(docId, 0, 420); + const blob = new Blob([new Uint8Array(pngBuffer)], { type: "image/png" }); + return URL.createObjectURL(blob); + } finally { + if (docId !== null) { + await client.closeDocument(docId).catch(() => {}); + } + } +} + +async function renderPdfThumbnail(pdfPath: string): Promise { + return renderPdfThumbnailFromBytes(await readFile(pdfPath)); +} + +function texPreviewLines(content: string) { + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(0, 12) + .map((line) => (line.length > 70 ? `${line.slice(0, 67)}...` : line)); +} + +function statDateToMs(value: unknown): number | null { + if (!value) return null; + if (value instanceof Date) { + const time = value.getTime(); + return Number.isFinite(time) ? time : null; + } + if (typeof value === "number") { + if (!Number.isFinite(value) || value <= 0) return null; + return value < 1_000_000_000_000 ? value * 1000 : value; + } + if (typeof value === "string") { + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; + } + return null; +} + +async function getProjectCreatedAt( + projectPath: string, +): Promise { + try { + const info = (await stat(projectPath)) as { + birthtime?: unknown; + ctime?: unknown; + mtime?: unknown; + }; + return ( + statDateToMs(info.birthtime) ?? + statDateToMs(info.ctime) ?? + statDateToMs(info.mtime) + ); + } catch { + return null; + } +} + +function formatProjectCreatedDate(createdAt: number | null) { + if (!createdAt) return ""; + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }).format(new Date(createdAt)); +} + +async function loadProjectPreview( + project: RecentProject, +): Promise { + const cacheKey = projectPreviewCacheKey(project); + const cached = projectPreviewCache.get(cacheKey); + if (cached) return cached; + + const pending = projectPreviewRequests.get(cacheKey); + if (pending) return pending; + + const promise = (async () => { + const createdAt = await getProjectCreatedAt(project.path); + const pdfPath = await firstExistingPath(project.path, [ + [".prism", "build", "main.pdf"], + [".prism", "build", "document.pdf"], + ["main.pdf"], + ["document.pdf"], + ]); + + if (pdfPath) { + const data: ProjectPreviewData = { + kind: "pdf", + url: await renderPdfThumbnail(pdfPath), + createdAt, + }; + projectPreviewCache.set(cacheKey, data); + return data; + } + + const texFile = await firstExistingProjectFile(project.path, [ + ["main.tex"], + ["document.tex"], + ]); + + if (texFile) { + try { + const useTexlive = + useSettingsStore.getState().compilerBackend === "texlive"; + const pdfBytes = await enqueueProjectPreviewCompile(() => + compileLatex(project.path, texFile.relativePath, useTexlive), + ); + const data: ProjectPreviewData = { + kind: "pdf", + url: await renderPdfThumbnailFromBytes(pdfBytes), + createdAt, + }; + projectPreviewCache.set(cacheKey, data); + return data; + } catch (err) { + console.warn("Failed to compile project preview:", { + path: project.path, + target: texFile.relativePath, + error: err, + }); + } + + const fileName = texFile.absolutePath.split(/[\\/]/).pop() ?? "main.tex"; + const data: ProjectPreviewData = { + kind: "tex", + fileName, + lines: texPreviewLines(await readTextFile(texFile.absolutePath)), + createdAt, + }; + projectPreviewCache.set(cacheKey, data); + return data; + } + + const data: ProjectPreviewData = { kind: "empty", createdAt }; + projectPreviewCache.set(cacheKey, data); + return data; + })(); + + projectPreviewRequests.set(cacheKey, promise); + try { + return await promise; + } finally { + projectPreviewRequests.delete(cacheKey); + } +} + +function ProjectPreviewCard({ + project, + disabled, + onOpen, + onRemove, +}: { + project: RecentProject; + disabled: boolean; + onOpen: () => void; + onRemove: () => void; +}) { + const [preview, setPreview] = useState(() => { + const cached = projectPreviewCache.get(projectPreviewCacheKey(project)); + return cached ? { status: "ready", data: cached } : { status: "loading" }; + }); + const createdDateLabel = + preview.status === "ready" + ? formatProjectCreatedDate(preview.data.createdAt) + : ""; + + useEffect(() => { + let cancelled = false; + const cacheKey = projectPreviewCacheKey(project); + const cached = projectPreviewCache.get(cacheKey); + if (cached) { + setPreview({ status: "ready", data: cached }); + return; + } + + setPreview({ status: "loading" }); + loadProjectPreview(project) + .then((data) => { + if (!cancelled) setPreview({ status: "ready", data }); + }) + .catch((err) => { + console.warn("Failed to load project preview:", { + path: project.path, + error: err, + }); + if (!cancelled) setPreview({ status: "error" }); + }); + + return () => { + cancelled = true; + }; + }, [project]); + + return ( +
+
+ + +
+ +
+ {createdDateLabel} +
+
+ ); +} + +function ProjectPreviewSurface({ + preview, + projectName, +}: { + preview: ProjectPreviewState; + projectName: string; +}) { + if (preview.status === "loading") { + return ( +
+ +
+ ); + } + + if (preview.status === "ready" && preview.data.kind === "pdf") { + return ( + {`${projectName} + ); + } + + if (preview.status === "ready" && preview.data.kind === "tex") { + return ( +
+
+ + {preview.data.fileName} + +
+
+ {preview.data.lines.map((line, index) => ( +
+ {line} +
+ ))} +
+
+ ); + } + + return ( +
+ + No preview +
+ ); +} + +function ProjectNavButton({ + active, + collapsed, + icon: Icon, + onClick, + children, +}: { + active: boolean; + collapsed: boolean; + icon: LucideIcon; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + function EnvironmentStatus() { const [showAiSetup, setShowAiSetup] = useState(false); const claudeVersion = useClaudeSetupStore((s) => s.version); @@ -370,10 +942,9 @@ function EnvironmentStatus() { }, [_finishUvInstall]); // Lazy load skills onboarding - const [OnboardingComponent, setOnboardingComponent] = - useState void; - }> | null>(null); + const [OnboardingComponent, setOnboardingComponent] = useState void; + }> | null>(null); useEffect(() => { if (showSkillsOnboarding && !OnboardingComponent) { @@ -387,7 +958,7 @@ function EnvironmentStatus() { return ( <> -
+
{/* AI provider — always ready here */}