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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ The session UI is built for small screens first, then scales up to desktop. Tabs

### Workspace Explorer

The responsive Explorer opens the active session's working directory as a lazy-loaded file tree and a full CodeMirror editor. It supports syntax highlighting, multiple closeable tabs, conflict-aware saves, search and editor shortcuts, line wrapping, pinch or slider font resizing, and a resizable or collapsible tree. Desktop keeps chat, tree, and editor visible together; phones and touch-first foldables switch cleanly between the tree and editor without summoning the keyboard until the editor is tapped.
The responsive Explorer opens the active session's working directory as a lazy-loaded file tree and a full CodeMirror editor. A dedicated Artifacts scope presents generated project output as a visual gallery, with large interactive previews for images, sandboxed HTML, rendered Markdown, video, and PDFs—without digging through Pi's internal storage folders. Workspace and Artifacts each preserve their folder, scroll, and preview state when switching views or reopening the panel. Browser Back returns an open file or artifact to its prior tree or gallery before closing the panel on the next step. The Explorer supports syntax highlighting, multiple closeable tabs, conflict-aware saves, search and editor shortcuts, line wrapping, pinch or slider font resizing, and a resizable or collapsible tree. Desktop keeps chat, tree, and editor visible together; phones and touch-first foldables switch cleanly between the tree and editor without summoning the keyboard until the editor is tapped.

File access stays scoped to the session working directory. The server rejects path traversal and escaping symlinks, detects binary and oversized files, writes atomically, and uses revisions to prevent silently overwriting changes made elsewhere.

Expand Down
47 changes: 44 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,41 @@ <h2>Scan to connect to pi web</h2>
<div class="filesPanelHeader">
<button id="fileBackButton" class="fileHeaderBackButton" type="button" aria-label="Back to files" title="Back to files">‹</button>
<div class="filesPanelHeading">
<span class="filesPanelHeadingIcon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 6h6l2 2h10v10H3z"/><path d="M8 12v4m0-2h5m0-2v4m0-2h3"/></svg></span>
<h2>Explorer</h2>
<span class="filesPanelHeadingIcon" aria-hidden="true">
<svg class="filesPanelHeadingIconWorkspace" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 6h6l2 2h10v10H3z"/><path d="M8 12v4m0-2h5m0-2v4m0-2h3"/></svg>
<svg class="filesPanelHeadingIconArtifact" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m12 3 1.35 3.65L17 8l-3.65 1.35L12 13l-1.35-3.65L7 8l3.65-1.35L12 3Z"/><path d="m18 13 .85 2.15L21 16l-2.15.85L18 19l-.85-2.15L15 16l2.15-.85L18 13Z"/></svg>
</span>
<h2 id="filesPanelHeadingLabel">Explorer</h2>
</div>
<span id="fileStatus" class="fileStatus" aria-live="polite"></span>
<button id="fileSaveButton" class="fileHeaderSaveButton" type="button" disabled>Save</button>
<button id="filesRefreshButton" class="iconButton" type="button" aria-label="Refresh files" title="Refresh files">↻</button>
<button id="filesCloseButton" class="iconButton" type="button" aria-label="Close files" title="Close files">×</button>
</div>
<div class="filesPanelBody">
<nav class="filesExplorer" aria-label="File tree"><div id="filesTree" class="filesTree"></div></nav>
<nav class="filesExplorer" aria-label="File tree">
<div class="filesExplorerScopeBar">
<div class="filesExplorerScope" role="group" aria-label="Explorer location">
<button id="filesWorkspaceScope" class="fileExplorerScopeButton" type="button" aria-pressed="true" title="Browse workspace files">
<span class="fileExplorerScopeIcon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 6h6l2 2h10v10H3z"/></svg></span>
<span>Workspace</span>
</button>
<button id="filesArtifactsScope" class="fileExplorerScopeButton fileExplorerScopeButton--artifacts" type="button" aria-pressed="false" title="Browse generated artifacts">
<span class="fileExplorerScopeIcon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="m12 3 1.35 3.65L17 8l-3.65 1.35L12 13l-1.35-3.65L7 8l3.65-1.35L12 3Z"/><path d="m18 13 .85 2.15L21 16l-2.15.85L18 19l-.85-2.15L15 16l2.15-.85L18 13Z"/><path d="M5 14v5h8"/></svg></span>
<span>Artifacts</span>
</button>
</div>
</div>
<div id="artifactsGalleryToolbar" class="artifactsGalleryToolbar">
<button id="artifactsGalleryBack" class="artifactsGalleryBack" type="button" aria-label="Back one artifact folder" title="Back one folder" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
</button>
<nav id="artifactsGalleryBreadcrumb" class="artifactsGalleryBreadcrumb" aria-label="Artifact folder"></nav>
<span id="artifactsGalleryCount" class="artifactsGalleryCount"></span>
</div>
<div id="filesTree" class="filesTree"></div>
<div id="artifactsTree" class="filesTree artifactGallery"></div>
</nav>
<div id="filesTreeResize" class="filesTreeResize" role="separator" aria-label="Resize file tree" aria-orientation="vertical" tabindex="0"><button id="filesTreeCollapse" type="button" aria-label="Collapse file tree" title="Collapse file tree">‹</button></div>
<section class="fileWorkspace">
<div class="fileTabsBar">
Expand All @@ -111,6 +136,22 @@ <h2>Explorer</h2>
</label>
</div>
</section>
<section id="artifactBrowserPreview" class="artifactBrowserPreview" aria-label="Artifact preview">
<header class="artifactBrowserPreviewHeader">
<button id="artifactBrowserPreviewBack" class="artifactBrowserPreviewBack" type="button" aria-label="Back to artifact gallery" title="Back to gallery">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
</button>
<div class="artifactBrowserPreviewIdentity">
<span>Artifact</span>
<strong id="artifactBrowserPreviewTitle"></strong>
</div>
<div class="artifactBrowserPreviewActions">
<a id="artifactBrowserPreviewOpen" href="#" target="_blank" rel="noopener noreferrer">Open</a>
<a id="artifactBrowserPreviewDownload" href="#" download>Download</a>
</div>
</header>
<div id="artifactBrowserPreviewBody" class="artifactBrowserPreviewBody"></div>
</section>
</div>
</aside>

Expand Down
75 changes: 64 additions & 11 deletions server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { createReadStream, existsSync, statSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { extname, join, resolve } from "node:path";
import { createServer as createViteServer, type ViteDevServer } from "vite";
Expand Down Expand Up @@ -38,6 +38,8 @@ const mockMode = process.env.PI_WEB_MOCK === "1";

const contentTypes: Record<string, string> = {
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".xhtml": "application/xhtml+xml",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
Expand All @@ -49,6 +51,12 @@ const contentTypes: Record<string, string> = {
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".ogv": "video/ogg",
".pdf": "application/pdf",
};

function sendJson(res: ServerResponse, status: number, value: unknown) {
Expand All @@ -60,8 +68,8 @@ function sendJson(res: ServerResponse, status: number, value: unknown) {
res.end(body);
}

function pipeReadStream(res: ServerResponse, file: string) {
const stream = createReadStream(file);
function pipeReadStream(res: ServerResponse, file: string, range?: { start: number; end: number }) {
const stream = createReadStream(file, range);
res.on("close", () => stream.destroy());
stream.pipe(res);
}
Expand All @@ -88,20 +96,62 @@ async function readBody(req: IncomingMessage): Promise<unknown> {
return text ? JSON.parse(text) : {};
}

function serveArtifact(req: IncomingMessage, res: ServerResponse) {
async function serveArtifact(req: IncomingMessage, res: ServerResponse, sessionScoped = false) {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
const artifactPath = decodeURIComponent(url.pathname.slice("/api/artifacts/".length));
if (!isValidArtifactPath(artifactPath)) return sendJson(res, 400, { ok: false, error: "Invalid artifact path" });

const artifactRoots = new Set([piCwd, ...knownCwds, ...sessionService.knownCwds()]);
const routePrefix = sessionScoped ? "/api/session-artifacts/" : "/api/artifacts/";
const routePath = url.pathname.slice(routePrefix.length);
const separator = routePath.indexOf("/");
const pathSessionId = sessionScoped && separator > 0 ? decodeURIComponent(routePath.slice(0, separator)) : "";
const artifactPath = decodeURIComponent(sessionScoped ? routePath.slice(separator + 1) : routePath);
if ((sessionScoped && !pathSessionId) || !isValidArtifactPath(artifactPath)) return sendJson(res, 400, { ok: false, error: "Invalid artifact path" });

let preferredCwd = "";
const requestedSessionId = pathSessionId || url.searchParams.get("sessionId");
if (requestedSessionId) {
try { preferredCwd = await sessionService.cwdForSessionId(requestedSessionId); } catch {
if (sessionScoped) return sendJson(res, 404, { ok: false, error: "Session not found" });
}
}
const artifactRoots = sessionScoped
? new Set([preferredCwd])
: new Set([...(preferredCwd ? [preferredCwd] : []), piCwd, ...knownCwds, ...sessionService.knownCwds()]);
const resolvedFile = findArtifactFile(artifactRoots, artifactPath);
if (!resolvedFile) return sendJson(res, 404, { ok: false, error: "Artifact not found" });

res.writeHead(200, {
const size = statSync(resolvedFile).size;
const headers: Record<string, string | number> = {
"content-type": contentTypes[extname(resolvedFile).toLowerCase()] || "application/octet-stream",
"content-length": size,
"accept-ranges": "bytes",
"cache-control": "no-store",
};
const rangeHeader = req.headers.range?.trim();
if (!rangeHeader) {
res.writeHead(200, headers);
pipeReadStream(res, resolvedFile);
return;
}

const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
let start = match?.[1] ? Number(match[1]) : 0;
let end = match?.[2] ? Number(match[2]) : size - 1;
if (match && !match[1] && match[2]) {
const suffixLength = Number(match[2]);
start = Math.max(0, size - suffixLength);
end = size - 1;
}
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
res.writeHead(416, { ...headers, "content-length": 0, "content-range": `bytes */${size}` });
res.end();
return;
}
end = Math.min(end, size - 1);
res.writeHead(206, {
...headers,
"content-length": end - start + 1,
"content-range": `bytes ${start}-${end}/${size}`,
});
pipeReadStream(res, resolvedFile);
pipeReadStream(res, resolvedFile, { start, end });
}

function serveStatic(req: IncomingMessage, res: ServerResponse) {
Expand Down Expand Up @@ -341,8 +391,11 @@ const server = createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);

if (url.pathname.startsWith("/api/")) {
if (method === "GET" && url.pathname.startsWith("/api/session-artifacts/")) {
return await serveArtifact(req, res, true);
}
if (method === "GET" && url.pathname.startsWith("/api/artifacts/")) {
return serveArtifact(req, res);
return await serveArtifact(req, res);
}

if (!isAuthorized(req)) return unauthorized(res);
Expand Down
25 changes: 23 additions & 2 deletions src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ export const defaultPiWebSettings: PiWebSettings = {
const tokenStorageKey = "pi-web-token";
const collapsedFoldersStorageKey = "pi-web-collapsed-session-folders";
const sessionIdUrlParam = "sessionId";
const sessionIdHistoryStateKey = "piWebSessionId";
const sessionScopedHistoryStateKeys = ["piWebArtifactView", "piWebWorkspaceView"] as const;

function consumeUrlToken() {
const urlToken = new URLSearchParams(location.search).get("token");
Expand All @@ -358,12 +360,31 @@ export function readActiveSessionIdFromUrl() {
return new URLSearchParams(location.search).get(sessionIdUrlParam) || "";
}

function objectHistoryState(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

export function readActiveSessionIdFromHistoryState(value: unknown = history.state): string | undefined {
const sessionId = objectHistoryState(value)[sessionIdHistoryStateKey];
return typeof sessionId === "string" ? sessionId : undefined;
}

export function syncActiveSessionIdHistoryState(sessionId: string) {
if (readActiveSessionIdFromHistoryState() === sessionId) return;
history.replaceState({ ...objectHistoryState(history.state), [sessionIdHistoryStateKey]: sessionId }, "");
}

export function writeActiveSessionIdToUrl(sessionId: string, mode: "push" | "replace" = "push") {
const url = new URL(location.href);
if (sessionId) url.searchParams.set(sessionIdUrlParam, sessionId);
else url.searchParams.delete(sessionIdUrlParam);
if (url.href === location.href) return;
history[mode === "replace" ? "replaceState" : "pushState"](null, "", url.toString());
if (url.href === location.href) {
syncActiveSessionIdHistoryState(sessionId);
return;
}
const nextState: Record<string, unknown> = { ...objectHistoryState(history.state), [sessionIdHistoryStateKey]: sessionId };
for (const key of sessionScopedHistoryStateKeys) delete nextState[key];
history[mode === "replace" ? "replaceState" : "pushState"](nextState, "", url.toString());
}

function readCollapsedSessionFolders() {
Expand Down
Loading