diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5d7f488..6b24777 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,6 +22,7 @@ "@xterm/xterm": "^6.0.0", "electron-updater": "^6.8.9", "esbuild": "^0.28.1", + "file-type": "^22.0.1", "node-pty": "^1.1.0", "ws": "^8.21.1" }, diff --git a/apps/desktop/src/main/files.ts b/apps/desktop/src/main/files.ts index bc16ea8..7e00a69 100644 --- a/apps/desktop/src/main/files.ts +++ b/apps/desktop/src/main/files.ts @@ -1,6 +1,9 @@ import { execFile } from "node:child_process"; -import { readdir } from "node:fs/promises"; +import { readdir, readFile, realpath, stat } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; import path from "node:path"; +import { fileTypeFromBuffer } from "file-type"; +import type { FilePreview } from "../shared/pi-types.ts"; /** * A ceiling, not a page size. The `@` menu never shows more than a screenful, @@ -37,13 +40,27 @@ const SKIP_DIRS = new Set([ * list of directories that are noise everywhere. */ export async function listProjectFiles(projectDir: string): Promise { - const tracked = await gitFiles(projectDir); + const tracked = await gitFiles(projectDir, MAX_FILES); if (tracked) return tracked; - const walked = await walk(projectDir); + const walked = await walk(projectDir, MAX_FILES); return walked.sort((a, b) => a.localeCompare(b)); } -function gitFiles(projectDir: string): Promise { +/** The file explorer is complete and omits Git entries that are absent on disk. */ +export async function listExplorerFiles(projectDir: string): Promise { + const files = await gitFiles(projectDir, Number.POSITIVE_INFINITY) ?? await walk(projectDir, Number.POSITIVE_INFINITY); + return (await Promise.all(files.map(async (file) => { + const target = await containedPath(projectDir, file); + if (!target) return null; + try { + return (await stat(target)).isFile() ? file : null; + } catch { + return null; + } + }))).filter((file): file is string => file !== null).toSorted((a, b) => a.localeCompare(b)); +} + +function gitFiles(projectDir: string, maxFiles: number): Promise { return new Promise((resolve) => { execFile( "git", @@ -52,17 +69,17 @@ function gitFiles(projectDir: string): Promise { (err, stdout) => { // Not a repository, or no git at all: both mean "walk it yourself". if (err) return resolve(null); - resolve(stdout.split("\0").filter(Boolean).slice(0, MAX_FILES)); + resolve(stdout.split("\0").filter(Boolean).slice(0, maxFiles)); }, ); }); } -async function walk(root: string): Promise { +async function walk(root: string, maxFiles: number): Promise { const files: string[] = []; const queue = [root]; - while (queue.length > 0 && files.length < MAX_FILES) { + while (queue.length > 0 && files.length < maxFiles) { const dir = queue.shift() as string; let entries; try { @@ -77,10 +94,79 @@ async function walk(root: string): Promise { continue; } if (!entry.isFile()) continue; - if (files.length >= MAX_FILES) break; + if (files.length >= maxFiles) break; files.push(path.relative(root, path.join(dir, entry.name)).split(path.sep).join("/")); } } return files; } + +const MAX_TEXT_BYTES = 1_500_000; +const MAX_IMAGE_BYTES = 8_000_000; + +const IMAGE_MIME_BY_EXT: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", +}; + +const MARKDOWN_EXTS = new Set([".md", ".markdown", ".mdx"]); + +/** A path resolved and confirmed to stay inside `projectDir`, or `null` if it doesn't. */ +async function containedPath(projectDir: string, file: string): Promise { + const target = resolve(projectDir, file); + let root: string; + let resolvedTarget: string; + try { + [root, resolvedTarget] = await Promise.all([realpath(projectDir), realpath(target)]); + } catch { + return null; + } + const rel = relative(root, resolvedTarget); + if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) return null; + return resolvedTarget; +} + +/** + * A read-only preview of one project file. + * + * Images are read whole and returned as a data URL; everything else is sniffed + * for a null byte in its first few KB, which is enough to tell prose and code + * from the compiled and media files a project also contains. + */ +export async function readFilePreview(projectDir: string, file: string): Promise { + const targetPath = await containedPath(projectDir, file); + if (!targetPath) return { error: "The file is outside this project." }; + + let info; + try { + info = await stat(targetPath); + } catch { + return { error: "That file could not be found." }; + } + if (!info.isFile()) return { error: "That is not a file." }; + + const ext = path.extname(targetPath).toLowerCase(); + const base = { path: file, size: info.size, mtimeMs: info.mtimeMs }; + + const imageMime = IMAGE_MIME_BY_EXT[ext]; + if (imageMime) { + if (info.size > MAX_IMAGE_BYTES) return { ...base, kind: "too-large" }; + const bytes = await readFile(targetPath); + return { ...base, kind: "image", dataUrl: `data:${imageMime};base64,${bytes.toString("base64")}` }; + } + + if (info.size > MAX_TEXT_BYTES) return { ...base, kind: "too-large" }; + + const bytes = await readFile(targetPath); + if (await fileTypeFromBuffer(bytes)) return { ...base, kind: "binary" }; + const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? "utf-16le" : bytes[0] === 0xfe && bytes[1] === 0xff ? "utf-16be" : "utf-8"; + const content = new TextDecoder(encoding).decode(bytes); + return { ...base, kind: MARKDOWN_EXTS.has(ext) ? "markdown" : "text", content }; +} diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 5fa9f21..c805646 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -13,7 +13,7 @@ import { getRepoHostContext } from "./repoHost.ts"; import { repoHostContextSchema } from "../shared/repo-host-types.ts"; import { installPackage, listPackages, removePackage, updatePackage } from "./packages.ts"; import { listSkills } from "./skills.ts"; -import { listProjectFiles } from "./files.ts"; +import { listExplorerFiles, listProjectFiles, readFilePreview } from "./files.ts"; import { prepareImages } from "./images.ts"; import { loadGraphicalExtensions } from "./extensions.ts"; import { listInstalledEditors, openFileIn, openProjectIn } from "./editors.ts"; @@ -379,6 +379,12 @@ const searchSessionsParamsSchema = z.object({ projectDirs: z.array(z.string().min(1).max(32_767)).max(100), query: z.string().min(1).max(500), }); +const projectFileParamsSchema = z.object({ projectDir: z.string().min(1).max(32_767), path: z.string().min(1).max(32_767) }); + +async function knownProject(projectDir: string): Promise { + const project = resolve(projectDir); + return (await loadState()).projects.some((entry) => resolve(entry.path) === project); +} const usageDashboardParamsSchema = z.object({ projects: z.array(z.object({ path: z.string().min(1).max(32_767), name: z.string().min(1).max(200) })).max(100), }); @@ -1070,6 +1076,20 @@ const handlers: HandlerMap = { return { files: [] }; } }, + listExplorerFiles: async ({ projectDir }) => { + try { + if (!await knownProject(projectDir)) return { files: [] }; + return { files: await listExplorerFiles(projectDir) }; + } catch { + return { files: [] }; + } + }, + readFilePreview: async (params) => { + const { projectDir, path: file } = projectFileParamsSchema.parse(params); + if (!await knownProject(projectDir)) return { error: "That project is not available." }; + const result = await readFilePreview(projectDir, file); + return "error" in result ? { error: result.error } : { preview: result }; + }, listPackages: async ({ projectDir }) => { try { diff --git a/apps/desktop/src/renderer/components/ContextPane.tsx b/apps/desktop/src/renderer/components/ContextPane.tsx index 0724f8b..f911ac2 100644 --- a/apps/desktop/src/renderer/components/ContextPane.tsx +++ b/apps/desktop/src/renderer/components/ContextPane.tsx @@ -25,6 +25,7 @@ import FileContextMenu from "./FileContextMenu.tsx"; import { ExtensionPanels } from "./ExtensionSlots.tsx"; import CommitDialog from "./CommitDialog.tsx"; import RepoHostPanel from "./RepoHostPanel.tsx"; +import FileExplorer from "./FileExplorer.tsx"; export default function ContextPane({ overlay = false, onClose }: { overlay?: boolean; onClose?: () => void }) { const git = useAppStore((s) => s.git); @@ -36,13 +37,14 @@ export default function ContextPane({ overlay = false, onClose }: { overlay?: bo const keybindingOverrides = useAppStore((s) => s.keybindingOverrides); const [selected, setSelected] = useState(null); const [committing, setCommitting] = useState(false); + const [files, setFiles] = useState(false); useEffect(() => setSelected(null), [projectDir]); return ( diff --git a/apps/desktop/src/renderer/components/FileExplorer.tsx b/apps/desktop/src/renderer/components/FileExplorer.tsx new file mode 100644 index 0000000..8053c4b --- /dev/null +++ b/apps/desktop/src/renderer/components/FileExplorer.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { useAppStore } from "../lib/store.ts"; +import FilePreview from "./FilePreview.tsx"; +import FileTree from "./FileTree.tsx"; + +/** + * The "Files" tab: a project tree that swaps for a read-only preview on + * selection, rather than splitting the already-narrow context pane in two. + */ +export default function FileExplorer({ projectDir }: { projectDir: string }) { + const recordFileOpened = useAppStore((s) => s.recordFileOpened); + const [selected, setSelected] = useState(null); + + useEffect(() => setSelected(null), [projectDir]); + + function select(path: string) { + recordFileOpened(projectDir, path); + setSelected(path); + } + + if (selected) { + return setSelected(null)} />; + } + return ; +} diff --git a/apps/desktop/src/renderer/components/FilePreview.tsx b/apps/desktop/src/renderer/components/FilePreview.tsx new file mode 100644 index 0000000..54912c2 --- /dev/null +++ b/apps/desktop/src/renderer/components/FilePreview.tsx @@ -0,0 +1,157 @@ +import { code } from "@streamdown/code"; +import { Streamdown } from "streamdown"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react/ArrowClockwise"; +import { ArrowLeftIcon } from "@phosphor-icons/react/ArrowLeft"; +import { CaretRightIcon } from "@phosphor-icons/react/CaretRight"; +import { FileArrowUpIcon } from "@phosphor-icons/react/FileArrowUp"; +import { FolderOpenIcon } from "@phosphor-icons/react/FolderOpen"; +import { fencedCodeBlock, languageFor } from "../lib/codeFence.ts"; +import { formatBytes } from "../lib/format.ts"; +import { gitStateBadge, gitStateColor, gitStateLabel } from "../lib/gitFileState.ts"; +import { absoluteProjectPath, editorName } from "../lib/paths.ts"; +import { rpc } from "../lib/rpc.ts"; +import { useAppStore } from "../lib/store.ts"; +import { useRequest } from "../lib/useRequest.ts"; +import { Button } from "@/components/ui/button.tsx"; +import { cn } from "@/lib/utils.ts"; +import FileTypeIcon from "./FileTypeIcon.tsx"; + +const streamdownPlugins = { code }; + +export default function FilePreview({ + projectDir, + path, + onBack, +}: { + projectDir: string; + path: string; + onBack: () => void; +}) { + const git = useAppStore((s) => s.git); + const editorId = useAppStore((s) => s.preferences.preferredEditorId); + const { data, error, reload } = useRequest( + () => rpc.request.readFilePreview({ projectDir, path }), + [projectDir, path], + ); + + const state = git?.files.find((f) => f.path === path); + const segments = path.split("/"); + const absolutePath = absoluteProjectPath(projectDir, path); + + return ( +
+
+ + + + +
+ +
+ + {path} + {data?.preview ? ( + <> + {formatBytes(data.preview.size)} + {new Date(data.preview.mtimeMs).toLocaleString()} + + ) : null} + {state ? ( + + + {gitStateLabel(state.state)} + + ) : null} +
+ +
+ {error || data?.error ? ( +
+ {data?.error ?? "Could not load this file."} + +
+ ) : !data ? ( +

Loading…

+ ) : !data.preview ? null : ( + + )} +
+
+ ); +} + +function FileContent({ + preview, +}: { + preview: NonNullable>["preview"]>; +}) { + if (preview.kind === "image") { + return {preview.path}; + } + if (preview.kind === "binary") { + return

This file can't be previewed.

; + } + if (preview.kind === "too-large") { + return ( +

+ This file is {formatBytes(preview.size)}, too large to preview here. +

+ ); + } + const content = + preview.kind === "markdown" ? preview.content : fencedCodeBlock(preview.content ?? "", languageFor(preview.path)); + return ( + + {content} + + ); +} diff --git a/apps/desktop/src/renderer/components/FileTree.tsx b/apps/desktop/src/renderer/components/FileTree.tsx new file mode 100644 index 0000000..2dee36a --- /dev/null +++ b/apps/desktop/src/renderer/components/FileTree.tsx @@ -0,0 +1,280 @@ +import { useMemo, useState, type ReactElement } from "react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react/ArrowClockwise"; +import { CaretDownIcon } from "@phosphor-icons/react/CaretDown"; +import { CaretRightIcon } from "@phosphor-icons/react/CaretRight"; +import { ChartLineUpIcon } from "@phosphor-icons/react/ChartLineUp"; +import { ClockCounterClockwiseIcon } from "@phosphor-icons/react/ClockCounterClockwise"; +import { FolderIcon } from "@phosphor-icons/react/Folder"; +import { FolderOpenIcon } from "@phosphor-icons/react/FolderOpen"; +import type { GitChangedFile, RecentFile } from "../../shared/pi-types.ts"; +import { gitStateBadge, gitStateColor, gitStateLabel } from "../lib/gitFileState.ts"; +import { useAppStore } from "../lib/store.ts"; +import { rpc } from "../lib/rpc.ts"; +import { useRequest } from "../lib/useRequest.ts"; +import { Button } from "@/components/ui/button.tsx"; +import { cn } from "@/lib/utils.ts"; +import FileContextMenu from "./FileContextMenu.tsx"; +import FileTypeIcon from "./FileTypeIcon.tsx"; + +interface TreeDir { + kind: "dir"; + name: string; + path: string; + children: TreeNode[]; +} +interface TreeFile { + kind: "file"; + name: string; + path: string; +} +type TreeNode = TreeDir | TreeFile; + +function buildTree(files: string[]): TreeDir { + const root: TreeDir = { kind: "dir", name: "", path: "", children: [] }; + for (const file of files) { + const parts = file.split("/"); + let dir = root; + for (let i = 0; i < parts.length - 1; i++) { + const segment = parts[i] as string; + const path = parts.slice(0, i + 1).join("/"); + let next = dir.children.find((c): c is TreeDir => c.kind === "dir" && c.name === segment); + if (!next) { + next = { kind: "dir", name: segment, path, children: [] }; + dir.children.push(next); + } + dir = next; + } + dir.children.push({ kind: "file", name: parts[parts.length - 1] as string, path: file }); + } + sortTree(root); + return root; +} + +function sortTree(dir: TreeDir): void { + dir.children.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1)); + for (const child of dir.children) if (child.kind === "dir") sortTree(child); +} + +const ROW_CLASS = + "flex min-h-7 w-full items-center gap-1.5 rounded-md px-1.5 text-left text-xs outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-inset"; + +function GitBadge({ file, gitByPath }: { file: string; gitByPath: Map }) { + const state = gitByPath.get(file); + if (!state) return null; + return ( + + + {gitStateLabel(state.state)} + + ); +} + +function TreeRow({ + node, + depth, + expanded, + onToggle, + onSelect, + gitByPath, + projectDir, +}: { + node: TreeNode; + depth: number; + expanded: Set; + onToggle: (path: string) => void; + onSelect: (path: string) => void; + gitByPath: Map; + projectDir: string; +}) { + if (node.kind === "dir") { + const isOpen = expanded.has(node.path); + return ( +
+ + {isOpen + ? node.children.map((child) => ( + + )) + : null} +
+ ); + } + + const state = gitByPath.get(node.path); + return ( + + + + ); +} + +function QuickList({ + icon, + label, + entries, + onSelect, + gitByPath, + projectDir, +}: { + icon: ReactElement; + label: string; + entries: RecentFile[]; + onSelect: (path: string) => void; + gitByPath: Map; + projectDir: string; +}) { + return ( +
+
+ {icon} + {label} +
+ {entries.map((entry) => { + const state = gitByPath.get(entry.path); + return ( + + + + ); + })} +
+ ); +} + +export default function FileTree({ projectDir, onSelect }: { projectDir: string; onSelect: (path: string) => void }) { + const git = useAppStore((s) => s.git); + const refreshGit = useAppStore((s) => s.refreshGit); + const recent = useAppStore((s) => s.recentFilesByProject[projectDir] ?? []); + const { data, error, reload } = useRequest(() => rpc.request.listExplorerFiles({ projectDir }), [projectDir, git]); + const tree = useMemo(() => buildTree(data?.files ?? []), [data]); + const gitByPath = useMemo(() => new Map((git?.files ?? []).map((f) => [f.path, f])), [git]); + const [expanded, setExpanded] = useState>(() => new Set()); + + function toggle(path: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + } + + const recentTop = useMemo(() => [...recent].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt).slice(0, 5), [recent]); + const frequent = useMemo( + () => + recent + .filter((f) => f.openCount > 1) + .sort((a, b) => b.openCount - a.openCount) + .slice(0, 5), + [recent], + ); + + if (error) { + return ( +
+ Could not list project files. + +
+ ); + } + + if (!data) return

Loading…

; + + if (data.files.length === 0) { + return

This project has no files.

; + } + + return ( +
+ + {recentTop.length > 0 ? ( + } + label="Recent" + entries={recentTop} + onSelect={onSelect} + gitByPath={gitByPath} + projectDir={projectDir} + /> + ) : null} + {frequent.length > 0 ? ( + } + label="Frequent" + entries={frequent} + onSelect={onSelect} + gitByPath={gitByPath} + projectDir={projectDir} + /> + ) : null} +
+ {tree.children.map((child) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/ui/tabs.tsx b/apps/desktop/src/renderer/components/ui/tabs.tsx new file mode 100644 index 0000000..6bcac77 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/tabs.tsx @@ -0,0 +1,80 @@ +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/apps/desktop/src/renderer/lib/codeFence.ts b/apps/desktop/src/renderer/lib/codeFence.ts new file mode 100644 index 0000000..e158cd1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/codeFence.ts @@ -0,0 +1,33 @@ +/** Extensions Shiki's grammar list does not already accept as an identifier. */ +const LANGUAGE_BY_EXT: Record = { + cjs: "javascript", + mjs: "javascript", + cts: "typescript", + mts: "typescript", + ps1: "powershell", + psm1: "powershell", + yml: "yaml", + md: "markdown", + mdx: "markdown", + htm: "html", + h: "c", + hpp: "cpp", + cc: "cpp", + dockerfile: "docker", +}; + +/** The Shiki language id for a file, guessed from its extension. */ +export function languageFor(path: string): string { + const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase(); + return LANGUAGE_BY_EXT[ext] ?? ext; +} + +/** + * Wrap `content` in a fenced code block long enough that a run of backticks + * already inside it can't close the fence early. + */ +export function fencedCodeBlock(content: string, language: string): string { + const longestRun = Math.max(3, ...(content.match(/`+/g) ?? []).map((run) => run.length + 1)); + const fence = "`".repeat(longestRun); + return `${fence}${language}\n${content}\n${fence}`; +} diff --git a/apps/desktop/src/renderer/lib/format.ts b/apps/desktop/src/renderer/lib/format.ts index 893680f..6b7741a 100644 --- a/apps/desktop/src/renderer/lib/format.ts +++ b/apps/desktop/src/renderer/lib/format.ts @@ -35,3 +35,15 @@ export function formatLineDelta(added: number, removed: number): string { export function pluralize(count: number, singular: string, plural = `${singular}s`): string { return `${count} ${count === 1 ? singular : plural}`; } + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; +} diff --git a/apps/desktop/src/renderer/lib/gitFileState.ts b/apps/desktop/src/renderer/lib/gitFileState.ts new file mode 100644 index 0000000..f5ed776 --- /dev/null +++ b/apps/desktop/src/renderer/lib/gitFileState.ts @@ -0,0 +1,25 @@ +import type { GitChangedFile } from "../../shared/pi-types.ts"; + +export function gitStateBadge(state: GitChangedFile["state"]): string { + return state === "added" ? "A" : state === "deleted" ? "D" : state === "renamed" ? "R" : state === "untracked" ? "U" : "M"; +} + +/** The badge letter's full word, for hover and assistive tech. */ +export function gitStateLabel(state: GitChangedFile["state"]): string { + return state === "added" + ? "Added" + : state === "deleted" + ? "Deleted" + : state === "renamed" + ? "Renamed" + : state === "untracked" + ? "Untracked" + : "Modified"; +} + +export function gitStateColor(state: GitChangedFile["state"]): string { + if (state === "added") return "text-success"; + if (state === "deleted") return "text-destructive"; + if (state === "untracked") return "text-info"; + return "text-warning"; +} diff --git a/apps/desktop/src/renderer/lib/store/internals.ts b/apps/desktop/src/renderer/lib/store/internals.ts index 27da65c..bc61ef8 100644 --- a/apps/desktop/src/renderer/lib/store/internals.ts +++ b/apps/desktop/src/renderer/lib/store/internals.ts @@ -84,6 +84,7 @@ export function persist(get: GetState): void { projects: s.projects, lastProjectPath: s.activeProjectPath ?? undefined, lastChatByProject: map, + recentFilesByProject: s.recentFilesByProject, drafts: s.drafts, favoriteModels: s.favoriteModels ?? [], pinnedChats: s.pinnedChats, diff --git a/apps/desktop/src/renderer/lib/store/projectContext.ts b/apps/desktop/src/renderer/lib/store/projectContext.ts index 43b924c..d20396a 100644 --- a/apps/desktop/src/renderer/lib/store/projectContext.ts +++ b/apps/desktop/src/renderer/lib/store/projectContext.ts @@ -17,6 +17,7 @@ export const createProjectContextSlice: SliceCreator = (set extSurfaces: [], extTriggers: [], extUiState: NO_EXTENSION_UI_STATE, + recentFilesByProject: {}, refreshGit: async () => { const path = get().activeProjectPath; @@ -124,4 +125,15 @@ export const createProjectContextSlice: SliceCreator = (set return; } }, + + recordFileOpened: (projectPath, path) => { + set((s) => { + const existing = s.recentFilesByProject[projectPath] ?? []; + const prior = existing.find((f) => f.path === path); + const entry = { path, lastOpenedAt: Date.now(), openCount: (prior?.openCount ?? 0) + 1 }; + const rest = existing.filter((f) => f.path !== path); + return { recentFilesByProject: { ...s.recentFilesByProject, [projectPath]: [entry, ...rest].slice(0, 30) } }; + }); + persist(get); + }, }); diff --git a/apps/desktop/src/renderer/lib/store/types.ts b/apps/desktop/src/renderer/lib/store/types.ts index 12caa80..577cd5e 100644 --- a/apps/desktop/src/renderer/lib/store/types.ts +++ b/apps/desktop/src/renderer/lib/store/types.ts @@ -14,6 +14,7 @@ import type { GitStatus, ModelInfo, PiEvent, + RecentFile, SessionEntry, SessionSummary, ThinkingLevel, @@ -245,6 +246,7 @@ export interface ProjectContextSlice { /** The characters an extension autocomplete provider answers on, if any. */ extTriggers: string[]; extUiState: ExtensionUiState; + recentFilesByProject: Record; refreshGit: () => Promise; refreshRepoHost: () => Promise; @@ -252,6 +254,7 @@ export interface ProjectContextSlice { reloadExtensions: () => Promise; respondExtension: (value: { value?: string; confirmed?: boolean; cancel?: boolean }) => void; onTuiFrame: (payload: { projectDir: string; sessionFile?: string; frame: TuiHostFrame }) => void; + recordFileOpened: (projectPath: string, path: string) => void; } /** diff --git a/apps/desktop/src/renderer/lib/store/workspace.ts b/apps/desktop/src/renderer/lib/store/workspace.ts index fe9d57c..2322ae9 100644 --- a/apps/desktop/src/renderer/lib/store/workspace.ts +++ b/apps/desktop/src/renderer/lib/store/workspace.ts @@ -27,6 +27,7 @@ export const createWorkspaceSlice: SliceCreator = (set, get) => drafts: loaded.drafts ?? {}, favoriteModels: loaded.favoriteModels ?? [], pinnedChats: loaded.pinnedChats ?? [], + recentFilesByProject: loaded.recentFilesByProject ?? {}, activeProjectPath: restoreProject, reopenLastProject, preferences: loaded.preferences, diff --git a/apps/desktop/src/shared/pi-types.ts b/apps/desktop/src/shared/pi-types.ts index 7c9b0eb..a4f67df 100644 --- a/apps/desktop/src/shared/pi-types.ts +++ b/apps/desktop/src/shared/pi-types.ts @@ -229,6 +229,29 @@ export interface GitBranch { worktree?: string; } +/** + * A read-only preview of one project file. + * + * `kind` decides how the renderer shows `content`: markdown and code get syntax + * highlighting, image gets an `` from the data URL, and binary/too-large + * files show a message instead of file bytes. + */ +export interface FilePreview { + path: string; + size: number; + mtimeMs: number; + kind: "text" | "markdown" | "image" | "binary" | "too-large"; + content?: string; + /** Present only when `kind` is `"image"`. */ + dataUrl?: string; +} + +/** One project file the user opened from the explorer, recency- and count-tracked. */ +export interface RecentFile { + path: string; + lastOpenedAt: number; + openCount: number; +} /** One skill the composer's `$` menu can insert as a `/skill:name` command. */ export interface SkillInfo { diff --git a/apps/desktop/src/shared/rpc-schema.test.ts b/apps/desktop/src/shared/rpc-schema.test.ts index 7d19e3c..b2ab00d 100644 --- a/apps/desktop/src/shared/rpc-schema.test.ts +++ b/apps/desktop/src/shared/rpc-schema.test.ts @@ -8,6 +8,7 @@ test("an empty object yields the full set of defaults", () => { projects: [], lastProjectPath: undefined, lastChatByProject: {}, + recentFilesByProject: {}, drafts: {}, favoriteModels: [], pinnedChats: [], diff --git a/apps/desktop/src/shared/rpc-schema.ts b/apps/desktop/src/shared/rpc-schema.ts index a488d88..6a93dde 100644 --- a/apps/desktop/src/shared/rpc-schema.ts +++ b/apps/desktop/src/shared/rpc-schema.ts @@ -4,6 +4,7 @@ import type { CommandInfo, ExtensionUiResponse, FileEntry, + FilePreview, ForkPoint, GitBranch, GitDiff, @@ -249,6 +250,12 @@ export const nativePiStateSchema = z.object({ ), lastProjectPath: z.string().optional().catch(undefined), lastChatByProject: z.record(z.string(), z.string()).catch({}), + recentFilesByProject: z + .record( + z.string(), + z.array(z.object({ path: z.string(), lastOpenedAt: z.number(), openCount: z.number() })).catch([]), + ) + .catch({}), drafts: z.record(z.string(), z.string()).catch({}), favoriteModels: z.array(z.string()).catch([]), pinnedChats: z @@ -504,6 +511,12 @@ export type HostRequests = { listCommands: { params: { projectDir: string }; response: { commands: CommandInfo[] } }; listSkills: { params: { projectDir: string }; response: { skills: SkillInfo[] } }; listProjectFiles: { params: { projectDir: string }; response: { files: string[] } }; + listExplorerFiles: { params: { projectDir: string }; response: { files: string[] } }; + /** A read-only preview of one project file, for the file explorer's preview pane. */ + readFilePreview: { + params: { projectDir: string; path: string }; + response: { preview?: FilePreview; error?: string }; + }; listPackages: { params: { projectDir: string }; diff --git a/bun.lock b/bun.lock index d545f69..1c362d7 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ }, "apps/desktop": { "name": "@nativepi/desktop", - "version": "0.10.2", + "version": "0.11.0", "dependencies": { "@earendil-works/pi-coding-agent": "0.82.1", "@earendil-works/pi-tui": "0.82.1", @@ -19,6 +19,7 @@ "@xterm/xterm": "^6.0.0", "electron-updater": "^6.8.9", "esbuild": "^0.28.1", + "file-type": "^22.0.1", "node-pty": "^1.1.0", "ws": "^8.21.1", }, @@ -235,6 +236,8 @@ "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], @@ -879,6 +882,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], @@ -1641,6 +1648,8 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -1785,6 +1794,8 @@ "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "immer": ["immer@11.1.15", "", {}, "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ=="], @@ -2579,6 +2590,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], @@ -2635,6 +2648,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],