Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
102 changes: 94 additions & 8 deletions apps/desktop/src/main/files.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -37,13 +40,27 @@ const SKIP_DIRS = new Set([
* list of directories that are noise everywhere.
*/
export async function listProjectFiles(projectDir: string): Promise<string[]> {
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<string[] | null> {
/** The file explorer is complete and omits Git entries that are absent on disk. */
export async function listExplorerFiles(projectDir: string): Promise<string[]> {
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<string[] | null> {
return new Promise((resolve) => {
execFile(
"git",
Expand All @@ -52,17 +69,17 @@ function gitFiles(projectDir: string): Promise<string[] | null> {
(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<string[]> {
async function walk(root: string, maxFiles: number): Promise<string[]> {
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 {
Expand All @@ -77,10 +94,79 @@ async function walk(root: string): Promise<string[]> {
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<string, string> = {
".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<string | null> {
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;
Comment thread
nonlooped marked this conversation as resolved.
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<FilePreview | { error: string }> {
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 };
}
22 changes: 21 additions & 1 deletion apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<boolean> {
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),
});
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/renderer/components/ContextPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -36,13 +37,14 @@ export default function ContextPane({ overlay = false, onClose }: { overlay?: bo
const keybindingOverrides = useAppStore((s) => s.keybindingOverrides);
const [selected, setSelected] = useState<GitChangedFile | null>(null);
const [committing, setCommitting] = useState(false);
const [files, setFiles] = useState(false);

useEffect(() => setSelected(null), [projectDir]);

return (
<aside className="context-pane flex h-full min-w-0 flex-col bg-sidebar text-sidebar-foreground">
<div className={cn("flex h-12 shrink-0 items-center gap-1 pr-2 pl-3", !overlay && WINDOW_CONTROLS_CLEARANCE)}>
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">Changes</span>
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{files ? "Files" : "Changes"}</span>
<div className="flex-1" />
<Button
variant="ghost"
Expand All @@ -56,6 +58,7 @@ export default function ContextPane({ overlay = false, onClose }: { overlay?: bo
<Button variant="ghost" size="icon-sm" onClick={() => setCommitting(true)} disabled={!git?.isRepo} title="Commit changes" aria-label="Commit changes">
<GitCommitIcon />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setFiles((open) => !open)}>{files ? "Changes" : "Files"}</Button>
<Button
variant="ghost"
size="icon-sm"
Expand All @@ -68,6 +71,7 @@ export default function ContextPane({ overlay = false, onClose }: { overlay?: bo
</div>

<div className="min-h-0 flex-1 overflow-y-auto">
{files ? (projectDir ? <FileExplorer projectDir={projectDir} /> : <p className="px-3 py-4 text-xs text-muted-foreground">No project is open.</p>) : <>
{!git ? (
<p className="px-3 py-4 text-xs text-muted-foreground">Loading…</p>
) : !git.isRepo ? (
Expand Down Expand Up @@ -145,6 +149,7 @@ export default function ContextPane({ overlay = false, onClose }: { overlay?: bo
)}

<ExtensionPanels />
</>}
</div>
<CommitDialog projectDir={committing ? projectDir : null} onClose={() => setCommitting(false)} />
</aside>
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/renderer/components/FileExplorer.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

useEffect(() => setSelected(null), [projectDir]);

function select(path: string) {
recordFileOpened(projectDir, path);
setSelected(path);
}

if (selected) {
return <FilePreview projectDir={projectDir} path={selected} onBack={() => setSelected(null)} />;
}
return <FileTree projectDir={projectDir} onSelect={select} />;
}
Loading
Loading