mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
Refine project picker experience
This commit is contained in:
parent
28ab3a8828
commit
e8cac090b7
1 changed files with 664 additions and 93 deletions
|
|
@ -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<string, ProjectPreviewData>();
|
||||
const projectPreviewRequests = new Map<string, Promise<ProjectPreviewData>>();
|
||||
let projectPreviewCompileQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export function ProjectPicker() {
|
||||
const [showModeDialog, setShowModeDialog] = useState(false);
|
||||
const [wizardMode, setWizardMode] = useState<CreationMode | null>(null);
|
||||
const [appVersion, setAppVersion] = useState("");
|
||||
const [isRestoringProject, setIsRestoringProject] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<ProjectPickerSection>("projects");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [removeProjectTarget, setRemoveProjectTarget] =
|
||||
useState<RecentProject | null>(null);
|
||||
const recoveryAttemptedRef = useRef(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(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 (
|
||||
<ProjectWizard mode={wizardMode} onBack={() => setWizardMode(null)} />
|
||||
|
|
@ -175,93 +258,184 @@ export function ProjectPicker() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-8 px-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img src="/icon-192.png" alt="ClaudePrism" className="size-16" />
|
||||
<h1 className="font-bold text-2xl">ClaudePrism</h1>
|
||||
<VersionBadge
|
||||
version={appVersion}
|
||||
updateStatus={updateStatus}
|
||||
onCheck={checkForUpdate}
|
||||
onInstall={installUpdate}
|
||||
/>
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
AI-powered academic writing workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isClaudeReady ? <ClaudeSetup /> : <EnvironmentStatus />}
|
||||
|
||||
{isRestoringProject && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Restoring last project...</span>
|
||||
</div>
|
||||
<div className="flex h-full bg-background text-foreground">
|
||||
<aside
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col border-sidebar-border border-r bg-sidebar text-sidebar-foreground transition-[width] duration-200 ease-out",
|
||||
isSidebarCollapsed ? "w-16" : "w-56",
|
||||
)}
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[calc(48px+var(--titlebar-height))] items-center gap-2 px-3 pt-[var(--titlebar-height)]",
|
||||
isSidebarCollapsed ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<img src="/icon-192.png" alt="ClaudePrism" className="size-6" />
|
||||
<span className="truncate font-semibold text-sm">
|
||||
ClaudePrism
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowModeDialog(true)}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="flex-1 gap-2"
|
||||
disabled={isRestoringProject}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-lg text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsSidebarCollapsed((value) => !value)}
|
||||
aria-label={
|
||||
isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
|
||||
}
|
||||
>
|
||||
<FolderPlusIcon className="size-5" />
|
||||
New Project
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOpenFolder}
|
||||
size="lg"
|
||||
className="flex-1 gap-2"
|
||||
disabled={isRestoringProject}
|
||||
>
|
||||
<FolderOpenIcon className="size-5" />
|
||||
Open Folder
|
||||
<PanelLeftIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
isSidebarCollapsed && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recentProjects.length > 0 && (
|
||||
<div className="w-full">
|
||||
<div className="mb-3 flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<ClockIcon className="size-4" />
|
||||
<span>Recent Projects</span>
|
||||
<nav
|
||||
className={cn(
|
||||
"flex flex-col gap-1 px-2",
|
||||
isSidebarCollapsed && "items-center",
|
||||
)}
|
||||
>
|
||||
<ProjectNavButton
|
||||
active={activeSection === "projects"}
|
||||
collapsed={isSidebarCollapsed}
|
||||
icon={FolderOpenIcon}
|
||||
onClick={() => setActiveSection("projects")}
|
||||
>
|
||||
All Projects
|
||||
</ProjectNavButton>
|
||||
<ProjectNavButton
|
||||
active={activeSection === "settings"}
|
||||
collapsed={isSidebarCollapsed}
|
||||
icon={SettingsIcon}
|
||||
onClick={() => setActiveSection("settings")}
|
||||
>
|
||||
Settings
|
||||
</ProjectNavButton>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 flex-nowrap items-center gap-3 border-border/70 border-b bg-background px-5">
|
||||
<div className="mr-auto flex min-w-0 items-center">
|
||||
<h1 className="truncate font-semibold text-lg leading-none">
|
||||
{activeSection === "settings" ? "Settings" : "All Projects"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{activeSection === "projects" && (
|
||||
<div className="flex min-w-0 flex-1 flex-nowrap items-center justify-end gap-2">
|
||||
<div className="relative flex min-w-40 flex-1 items-center sm:max-w-sm">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<kbd className="pointer-events-none absolute top-1/2 right-2 flex h-6 min-w-10 -translate-y-1/2 items-center justify-center rounded-md border border-border/70 bg-muted/30 px-1.5 font-medium text-[11px] text-muted-foreground leading-none">
|
||||
{searchShortcutLabel}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleOpenFolder}
|
||||
variant="secondary"
|
||||
className="h-9 shrink-0 gap-1.5 rounded-lg px-3.5"
|
||||
disabled={isRestoringProject}
|
||||
>
|
||||
<FolderOpenIcon className="size-4" />
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowModeDialog(true)}
|
||||
className="h-9 shrink-0 gap-1.5 rounded-lg px-4"
|
||||
disabled={isRestoringProject}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{recentProjects.map((project) => (
|
||||
<div
|
||||
key={project.path}
|
||||
className="group flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-muted"
|
||||
>
|
||||
<button
|
||||
className="flex flex-1 flex-col items-start overflow-hidden text-left"
|
||||
onClick={() => handleOpenRecent(project.path)}
|
||||
>
|
||||
<span className="truncate font-medium text-sm">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{project.path}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeRecentProject(project.path);
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{activeSection === "settings" ? (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-6 py-5">
|
||||
<section className="rounded-lg border border-border/60 bg-background p-4">
|
||||
<h2 className="font-semibold text-sm">Application</h2>
|
||||
<div className="mt-3">
|
||||
<VersionBadge
|
||||
version={appVersion}
|
||||
updateStatus={updateStatus}
|
||||
onCheck={checkForUpdate}
|
||||
onInstall={installUpdate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold text-sm">Environment</h2>
|
||||
{!isClaudeReady ? <ClaudeSetup /> : <EnvironmentStatus />}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4 px-5 py-5">
|
||||
{isRestoringProject && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/70 bg-muted/20 px-4 py-3 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Restoring last project...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleProjects.length === 0 ? (
|
||||
<div className="flex min-h-80 flex-col items-center justify-center rounded-lg border border-border border-dashed bg-muted/10 px-6 text-center">
|
||||
<FileTextIcon className="mb-4 size-10 text-muted-foreground/70" />
|
||||
<h2 className="font-semibold text-lg">
|
||||
{normalizedSearch ? "No matching projects" : "No projects"}
|
||||
</h2>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-3">
|
||||
<Button
|
||||
onClick={() => setShowModeDialog(true)}
|
||||
disabled={isRestoringProject}
|
||||
>
|
||||
<PlusIcon className="mr-2 size-4" />
|
||||
New
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOpenFolder}
|
||||
variant="outline"
|
||||
disabled={isRestoringProject}
|
||||
>
|
||||
<FolderOpenIcon className="mr-2 size-4" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,190px))] gap-x-5 gap-y-6">
|
||||
{visibleProjects.map((project) => (
|
||||
<ProjectPreviewCard
|
||||
key={project.path}
|
||||
project={project}
|
||||
disabled={isRestoringProject}
|
||||
onOpen={() => handleOpenRecent(project.path)}
|
||||
onRemove={() => setRemoveProjectTarget(project)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* New Project mode selection dialog */}
|
||||
<Dialog open={showModeDialog} onOpenChange={setShowModeDialog}>
|
||||
|
|
@ -273,10 +447,10 @@ export function ProjectPicker() {
|
|||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => handleSelectMode("template")}
|
||||
className="group flex flex-1 flex-col items-center gap-3 rounded-xl border border-foreground/10 p-5 text-center transition-all hover:border-foreground/20 hover:bg-muted/50"
|
||||
className="group flex flex-1 flex-col items-center gap-3 rounded-lg border border-border/70 p-4 text-center transition-colors hover:border-border hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-lg bg-muted/50 transition-colors group-hover:bg-muted">
|
||||
<SparklesIcon className="size-6 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted/50 transition-colors group-hover:bg-muted">
|
||||
<SparklesIcon className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">Guided Setup</div>
|
||||
|
|
@ -284,17 +458,17 @@ 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 font-medium text-[10px] text-muted-foreground">
|
||||
<span className="rounded-md bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground">
|
||||
Recommended
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelectMode("scratch")}
|
||||
className="group flex flex-1 flex-col items-center gap-3 rounded-xl border border-border p-5 text-center transition-all hover:border-foreground/20 hover:bg-muted/50"
|
||||
className="group flex flex-1 flex-col items-center gap-3 rounded-lg border border-border/70 p-4 text-center transition-colors hover:border-border hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-lg bg-muted/50 transition-colors group-hover:bg-muted">
|
||||
<FileTextIcon className="size-6 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted/50 transition-colors group-hover:bg-muted">
|
||||
<FileTextIcon className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">Blank Document</div>
|
||||
|
|
@ -306,6 +480,42 @@ export function ProjectPicker() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!removeProjectTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveProjectTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove "{removeProjectTarget?.name ?? "this project"}" from All
|
||||
Projects? The project files will stay on disk.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRemoveProjectTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!removeProjectTarget) return;
|
||||
removeRecentProject(removeProjectTarget.path);
|
||||
setRemoveProjectTarget(null);
|
||||
}}
|
||||
disabled={!removeProjectTarget}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -318,6 +528,368 @@ interface SkillsStatus {
|
|||
location: string;
|
||||
}
|
||||
|
||||
function projectPreviewCacheKey(project: RecentProject) {
|
||||
return `${project.path}:${project.lastOpened}`;
|
||||
}
|
||||
|
||||
function enqueueProjectPreviewCompile<T>(task: () => Promise<T>): Promise<T> {
|
||||
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<string | null> {
|
||||
return (
|
||||
(await firstExistingProjectFile(projectPath, candidates))?.absolutePath ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function renderPdfThumbnailFromBytes(bytes: Uint8Array): Promise<string> {
|
||||
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<string> {
|
||||
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<number | null> {
|
||||
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<ProjectPreviewData> {
|
||||
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<ProjectPreviewState>(() => {
|
||||
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 (
|
||||
<div className="group min-w-0">
|
||||
<div className="relative">
|
||||
<button
|
||||
className="relative aspect-[3/4] w-full overflow-hidden rounded-lg border border-border/70 bg-background text-left transition-all duration-200 hover:border-foreground/20 hover:shadow-md"
|
||||
onClick={onOpen}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ProjectPreviewSurface preview={preview} projectName={project.name} />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 size-7 bg-background/80 opacity-0 shadow-sm backdrop-blur-sm transition-opacity focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${project.name}`}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 block w-full truncate text-left font-medium text-sm leading-tight hover:underline"
|
||||
onClick={onOpen}
|
||||
disabled={disabled}
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
<div className="mt-1 h-4 truncate text-left text-muted-foreground text-xs">
|
||||
{createdDateLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectPreviewSurface({
|
||||
preview,
|
||||
projectName,
|
||||
}: {
|
||||
preview: ProjectPreviewState;
|
||||
projectName: string;
|
||||
}) {
|
||||
if (preview.status === "loading") {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted/10">
|
||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "ready" && preview.data.kind === "pdf") {
|
||||
return (
|
||||
<img
|
||||
src={preview.data.url}
|
||||
alt={`${projectName} preview`}
|
||||
className="h-full w-full bg-white object-cover object-top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "ready" && preview.data.kind === "tex") {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden bg-background">
|
||||
<div className="flex h-7 items-center border-border/60 border-b px-2">
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{preview.data.fileName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 px-2 py-2 font-mono text-[10px] text-muted-foreground">
|
||||
{preview.data.lines.map((line, index) => (
|
||||
<div key={`${index}-${line}`} className="truncate">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-muted/10 text-muted-foreground">
|
||||
<FileTextIcon className="mb-2 size-5" />
|
||||
<span className="text-xs">No preview</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectNavButton({
|
||||
active,
|
||||
collapsed,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
collapsed: boolean;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-10 items-center rounded-lg font-medium text-sm transition-colors",
|
||||
collapsed
|
||||
? "w-10 justify-center"
|
||||
: "w-full justify-start gap-3 px-3 text-left",
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
title={typeof children === "string" ? children : undefined}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{!collapsed && <span className="truncate">{children}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<React.ComponentType<{
|
||||
onClose: () => void;
|
||||
}> | null>(null);
|
||||
const [OnboardingComponent, setOnboardingComponent] = useState<ComponentType<{
|
||||
onClose: () => void;
|
||||
}> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSkillsOnboarding && !OnboardingComponent) {
|
||||
|
|
@ -387,7 +958,7 @@ function EnvironmentStatus() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="flex w-full flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
|
||||
{/* AI provider — always ready here */}
|
||||
<StatusRow
|
||||
ok={true}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue