pi-studio 0.9.14 → 0.9.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/README.md +2 -1
- package/client/studio-client.js +320 -19
- package/client/studio.css +142 -0
- package/index.ts +139 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.15] — 2026-05-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Added a first-cut right-pane **Files** view for browsing the current Studio resource directory, opening folders, opening text/code documents in the editor, previewing local PDFs/images, copying paths, and revealing files in the file manager.
|
|
11
|
+
- Added text-file recognition for extensionless/dotfile project files such as `.gitignore`, `.gitattributes`, `.env`, `Dockerfile`, `Makefile`, `Justfile`, `LICENSE`, and `README`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- The Files view now treats the Pi session directory/resource directory as its navigation root, while opening initially near the active file when applicable.
|
|
15
|
+
- Renamed **Clear editor** to **Reset editor** and clarified that it resets the tab to a fresh blank draft without changing saved files or responses.
|
|
16
|
+
- Browser-tab workspace restoration now uses per-tab session storage instead of shared local storage.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Fixed editor-only companion tabs inheriting a Files right-pane state from another Studio tab after opening a document in a new editor.
|
|
20
|
+
|
|
7
21
|
## [0.9.14] — 2026-05-22
|
|
8
22
|
|
|
9
23
|
### Added
|
|
@@ -19,7 +33,7 @@ All notable changes to `pi-studio` are documented here.
|
|
|
19
33
|
## [0.9.13] — 2026-05-22
|
|
20
34
|
|
|
21
35
|
### Added
|
|
22
|
-
- Added browser-refresh restoration for the Studio editor workspace, plus an explicit **
|
|
36
|
+
- Added browser-refresh restoration for the Studio editor workspace, plus an explicit **Reset editor** action for discarding the restored browser draft without touching saved files or response history.
|
|
23
37
|
- Added local preview-link handling for rendered document previews and sandboxed HTML previews: local PDF links open in Studio's embedded PDF focus viewer, local image links open in a local image preview, text/code/document links can open in a new editor tab, and right-clicking local links offers **Open here**, **Reveal in file manager**, and **Copy path** where applicable.
|
|
24
38
|
- Brought editor-only Studio closer to full Studio for document-local controls: pane resizing/focus shortcuts now work, and the annotation-header toggle remains available.
|
|
25
39
|
|
package/README.md
CHANGED
|
@@ -23,10 +23,11 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
|
|
|
23
23
|
- Includes a global **Zen** mode for hiding secondary Studio chrome without changing the current left/right pane layout
|
|
24
24
|
- Runs editor text directly, asks for structured critique (auto/writing/code focus), or opens **Quiz me** for a Studio-native active-recall loop over the current editor text, selection, current file, folder, or repo, with optional focus guidance for shaping question selection
|
|
25
25
|
- Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; when cycling response history, Working follows saved working details for the selected response when available
|
|
26
|
+
- Includes a right-pane **Files** view for browsing the current Pi session/resource directory, opening folders, loading text/code documents into the editor, previewing PDFs/images, copying paths, and revealing files in the file manager
|
|
26
27
|
- Includes an optional tmux-backed **REPL** view for Shell, Python, IPython, Julia, R, GHCi, and Clojure sessions, with Raw/Literate send modes, `Cmd/Ctrl+Shift+Enter` **Send to REPL**, session start/stop/interrupt controls, a compact refresh-persistent **Studio REPL Record** of user and Pi-sent code, a secondary raw tmux mirror, agent-facing `studio_repl_status` / `studio_repl_send` tools, and Markdown/PDF/HTML export
|
|
27
28
|
- Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them
|
|
28
29
|
- Includes a docked **Outline** rail for navigating document structure in the current editor text, with clickable entries that jump in the raw editor and reveal matching preview locations when available
|
|
29
|
-
- Restores the current browser-tab editor workspace after refresh and provides an explicit **
|
|
30
|
+
- Restores the current browser-tab editor workspace after refresh and provides an explicit **Reset editor** action when you want to discard the restored draft and return the tab to a fresh blank draft without changing responses or saved files
|
|
30
31
|
- Turns local preview links, including links inside sandboxed HTML previews, into Studio actions: PDFs open in the embedded viewer, text/code/document links can open in a new editor tab, and right-click menus provide **Open here**, **Reveal in file manager**, and **Copy path** for local resources
|
|
31
32
|
- Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with transient **Comment** / **Jump** actions from raw-editor selections plus editor-preview selections for Markdown, LaTeX, and code/text/diff previews, alongside optional inline `[an: ...]` toggles when you want comments reflected in the document text
|
|
32
33
|
- Browses response history (`Prev/Next/Last`) and loads either:
|
package/client/studio-client.js
CHANGED
|
@@ -1955,6 +1955,19 @@
|
|
|
1955
1955
|
const DEFAULT_RESPONSE_FONT_SIZE = studioUiRefreshEnabled ? 13.5 : 15;
|
|
1956
1956
|
let editorFontSize = DEFAULT_EDITOR_FONT_SIZE;
|
|
1957
1957
|
let responseFontSize = DEFAULT_RESPONSE_FONT_SIZE;
|
|
1958
|
+
let fileBrowserState = {
|
|
1959
|
+
rootDir: "",
|
|
1960
|
+
currentDir: "",
|
|
1961
|
+
relativeDir: "",
|
|
1962
|
+
parentDir: null,
|
|
1963
|
+
entries: [],
|
|
1964
|
+
omitted: 0,
|
|
1965
|
+
omittedIgnored: 0,
|
|
1966
|
+
loading: false,
|
|
1967
|
+
error: "",
|
|
1968
|
+
loaded: false,
|
|
1969
|
+
};
|
|
1970
|
+
let fileBrowserLoadNonce = 0;
|
|
1958
1971
|
let studioUiRefreshUi = null;
|
|
1959
1972
|
let studioZenModeEnabled = readStudioZenModeEnabled();
|
|
1960
1973
|
if (studioUiRefreshEnabled && document.body) {
|
|
@@ -4085,6 +4098,12 @@
|
|
|
4085
4098
|
}
|
|
4086
4099
|
if (referenceMetaEl instanceof HTMLElement) referenceMetaEl.hidden = false;
|
|
4087
4100
|
|
|
4101
|
+
if (rightView === "files") {
|
|
4102
|
+
const dir = fileBrowserState && fileBrowserState.currentDir ? fileBrowserState.currentDir : (getCurrentResourceDirValue() || "current Studio directory");
|
|
4103
|
+
referenceBadgeEl.textContent = "Files: " + dir;
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4088
4107
|
if (rightView === "trace") {
|
|
4089
4108
|
const state = traceState || createEmptyTraceState();
|
|
4090
4109
|
const context = traceDisplayContext || {};
|
|
@@ -6645,6 +6664,7 @@
|
|
|
6645
6664
|
critiqueViewEl.addEventListener("scroll", handleTracePaneScroll);
|
|
6646
6665
|
critiqueViewEl.addEventListener("click", handleTracePaneClick);
|
|
6647
6666
|
critiqueViewEl.addEventListener("click", handleReplPaneClick);
|
|
6667
|
+
critiqueViewEl.addEventListener("click", handleFilesPaneClick);
|
|
6648
6668
|
critiqueViewEl.addEventListener("change", handleReplPaneChange);
|
|
6649
6669
|
}
|
|
6650
6670
|
|
|
@@ -8107,6 +8127,241 @@
|
|
|
8107
8127
|
scheduleResponsePaneRepaintNudge();
|
|
8108
8128
|
}
|
|
8109
8129
|
|
|
8130
|
+
function getFileBrowserContextKey() {
|
|
8131
|
+
const context = getHtmlPreviewResourceContextOptions();
|
|
8132
|
+
return String(context.sourcePath || "") + "\n" + String(context.resourceDir || "");
|
|
8133
|
+
}
|
|
8134
|
+
|
|
8135
|
+
function getFileBrowserLocalLinkContext() {
|
|
8136
|
+
return { sourcePath: "", resourceDir: fileBrowserState.rootDir || getCurrentResourceDirValue() || "" };
|
|
8137
|
+
}
|
|
8138
|
+
|
|
8139
|
+
function formatFileBrowserSize(size) {
|
|
8140
|
+
const value = Number(size);
|
|
8141
|
+
if (!Number.isFinite(value) || value < 0) return "";
|
|
8142
|
+
if (value < 1024) return Math.round(value) + " B";
|
|
8143
|
+
if (value < 1024 * 1024) return (value / 1024).toFixed(value < 10 * 1024 ? 1 : 0) + " KB";
|
|
8144
|
+
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(value < 10 * 1024 * 1024 ? 1 : 0) + " MB";
|
|
8145
|
+
return (value / (1024 * 1024 * 1024)).toFixed(1) + " GB";
|
|
8146
|
+
}
|
|
8147
|
+
|
|
8148
|
+
function formatFileBrowserTime(ms) {
|
|
8149
|
+
const value = Number(ms);
|
|
8150
|
+
if (!Number.isFinite(value) || value <= 0) return "";
|
|
8151
|
+
try {
|
|
8152
|
+
return new Date(value).toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
|
|
8153
|
+
} catch {
|
|
8154
|
+
return "";
|
|
8155
|
+
}
|
|
8156
|
+
}
|
|
8157
|
+
|
|
8158
|
+
function getFileBrowserKindLabel(entry) {
|
|
8159
|
+
if (!entry || entry.type === "directory") return "folder";
|
|
8160
|
+
if (entry.kind === "text") return "document";
|
|
8161
|
+
if (entry.kind === "pdf") return "PDF";
|
|
8162
|
+
if (entry.kind === "image") return "image";
|
|
8163
|
+
return entry.extension ? entry.extension.replace(/^\./, "") : "file";
|
|
8164
|
+
}
|
|
8165
|
+
|
|
8166
|
+
function buildFileBrowserPanelHtml() {
|
|
8167
|
+
const state = fileBrowserState || {};
|
|
8168
|
+
const entries = Array.isArray(state.entries) ? state.entries : [];
|
|
8169
|
+
const currentDir = state.currentDir || "";
|
|
8170
|
+
const rootDir = state.rootDir || "";
|
|
8171
|
+
const relativeDir = state.relativeDir || ".";
|
|
8172
|
+
const parentDisabled = state.parentDir ? "" : " disabled";
|
|
8173
|
+
const rows = entries.length
|
|
8174
|
+
? entries.map((entry) => {
|
|
8175
|
+
const type = entry.type === "directory" ? "directory" : "file";
|
|
8176
|
+
const kind = entry.kind || (type === "directory" ? "directory" : "other");
|
|
8177
|
+
const icon = type === "directory" ? "📁" : (kind === "pdf" ? "📄" : (kind === "image" ? "🖼️" : (kind === "text" ? "📝" : "📦")));
|
|
8178
|
+
const metaParts = [];
|
|
8179
|
+
metaParts.push(getFileBrowserKindLabel(entry));
|
|
8180
|
+
if (type === "file") metaParts.push(formatFileBrowserSize(entry.size));
|
|
8181
|
+
const time = formatFileBrowserTime(entry.mtimeMs);
|
|
8182
|
+
if (time) metaParts.push(time);
|
|
8183
|
+
const textActions = kind === "text"
|
|
8184
|
+
? "<button type='button' data-files-action='open-new' data-files-path='" + escapeHtml(entry.path) + "'>New tab</button>"
|
|
8185
|
+
: "";
|
|
8186
|
+
const openTitle = type === "directory"
|
|
8187
|
+
? "Open folder"
|
|
8188
|
+
: (kind === "text" ? "Open in editor" : (kind === "pdf" ? "Open PDF preview" : (kind === "image" ? "Open image preview" : "Copy or reveal this file")));
|
|
8189
|
+
return "<div class='files-row files-row-" + escapeHtml(type) + " files-kind-" + escapeHtml(kind) + "'>"
|
|
8190
|
+
+ "<button type='button' class='files-open-btn' data-files-action='" + (type === "directory" ? "open-dir" : "open") + "' data-files-path='" + escapeHtml(entry.path) + "' data-files-kind='" + escapeHtml(kind) + "' title='" + escapeHtml(openTitle) + "'>"
|
|
8191
|
+
+ "<span class='files-icon' aria-hidden='true'>" + icon + "</span>"
|
|
8192
|
+
+ "<span class='files-name'>" + escapeHtml(entry.name) + "</span>"
|
|
8193
|
+
+ "<span class='files-meta'>" + escapeHtml(metaParts.filter(Boolean).join(" · ")) + "</span>"
|
|
8194
|
+
+ "</button>"
|
|
8195
|
+
+ "<span class='files-actions'>"
|
|
8196
|
+
+ textActions
|
|
8197
|
+
+ "<button type='button' data-files-action='copy-path' data-files-path='" + escapeHtml(entry.path) + "'>Copy path</button>"
|
|
8198
|
+
+ (type === "file" ? "<button type='button' data-files-action='reveal' data-files-path='" + escapeHtml(entry.path) + "'>Reveal</button>" : "")
|
|
8199
|
+
+ "</span>"
|
|
8200
|
+
+ "</div>";
|
|
8201
|
+
}).join("")
|
|
8202
|
+
: "<div class='files-empty'>" + (state.loading ? "Loading files…" : "This folder is empty.") + "</div>";
|
|
8203
|
+
const notices = [];
|
|
8204
|
+
if (state.error) notices.push("<div class='files-notice files-notice-error'>" + escapeHtml(state.error) + "</div>");
|
|
8205
|
+
if (state.omitted) notices.push("<div class='files-notice'>" + escapeHtml(String(state.omitted)) + " item" + (state.omitted === 1 ? "" : "s") + " omitted.</div>");
|
|
8206
|
+
if (state.omittedIgnored) notices.push("<div class='files-notice'>" + escapeHtml(String(state.omittedIgnored)) + " heavy/cache folder" + (state.omittedIgnored === 1 ? "" : "s") + " hidden.</div>");
|
|
8207
|
+
return "<div class='files-panel'>"
|
|
8208
|
+
+ "<div class='files-toolbar'>"
|
|
8209
|
+
+ "<div class='files-path-group'><span class='files-label'>Files</span><span class='files-path' title='" + escapeHtml(currentDir) + "'>" + escapeHtml(relativeDir || ".") + "</span></div>"
|
|
8210
|
+
+ "<div class='files-toolbar-actions'>"
|
|
8211
|
+
+ "<button type='button' data-files-action='parent'" + parentDisabled + ">Parent</button>"
|
|
8212
|
+
+ "<button type='button' data-files-action='refresh'>Refresh</button>"
|
|
8213
|
+
+ (rootDir ? "<button type='button' data-files-action='copy-root' data-files-path='" + escapeHtml(rootDir) + "'>Copy root</button>" : "")
|
|
8214
|
+
+ "</div>"
|
|
8215
|
+
+ "</div>"
|
|
8216
|
+
+ "<div class='files-subtitle'>Root: <span title='" + escapeHtml(rootDir) + "'>" + escapeHtml(rootDir || "current Studio directory") + "</span></div>"
|
|
8217
|
+
+ notices.join("")
|
|
8218
|
+
+ "<div class='files-list' role='list'>" + rows + "</div>"
|
|
8219
|
+
+ "</div>";
|
|
8220
|
+
}
|
|
8221
|
+
|
|
8222
|
+
function renderFilesView() {
|
|
8223
|
+
if (!critiqueViewEl) return;
|
|
8224
|
+
const contextKey = getFileBrowserContextKey();
|
|
8225
|
+
if (fileBrowserState.contextKey !== contextKey) {
|
|
8226
|
+
fileBrowserState = {
|
|
8227
|
+
rootDir: "",
|
|
8228
|
+
currentDir: "",
|
|
8229
|
+
relativeDir: "",
|
|
8230
|
+
parentDir: null,
|
|
8231
|
+
entries: [],
|
|
8232
|
+
omitted: 0,
|
|
8233
|
+
omittedIgnored: 0,
|
|
8234
|
+
loading: false,
|
|
8235
|
+
error: "",
|
|
8236
|
+
loaded: false,
|
|
8237
|
+
contextKey,
|
|
8238
|
+
};
|
|
8239
|
+
}
|
|
8240
|
+
finishPreviewRender(critiqueViewEl);
|
|
8241
|
+
critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
|
|
8242
|
+
critiqueViewEl.classList.remove("response-scroll-resetting");
|
|
8243
|
+
if (!fileBrowserState.loaded && !fileBrowserState.loading) {
|
|
8244
|
+
loadFileBrowserDirectory("");
|
|
8245
|
+
}
|
|
8246
|
+
scheduleResponsePaneRepaintNudge();
|
|
8247
|
+
}
|
|
8248
|
+
|
|
8249
|
+
async function loadFileBrowserDirectory(dir, options) {
|
|
8250
|
+
const context = getHtmlPreviewResourceContextOptions();
|
|
8251
|
+
const contextKey = getFileBrowserContextKey();
|
|
8252
|
+
const nonce = ++fileBrowserLoadNonce;
|
|
8253
|
+
fileBrowserState = {
|
|
8254
|
+
...fileBrowserState,
|
|
8255
|
+
contextKey,
|
|
8256
|
+
loading: true,
|
|
8257
|
+
error: "",
|
|
8258
|
+
};
|
|
8259
|
+
if (rightView === "files") {
|
|
8260
|
+
finishPreviewRender(critiqueViewEl);
|
|
8261
|
+
critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
|
|
8262
|
+
}
|
|
8263
|
+
try {
|
|
8264
|
+
const query = {};
|
|
8265
|
+
if (dir) query.dir = String(dir);
|
|
8266
|
+
if (context.sourcePath) query.sourcePath = context.sourcePath;
|
|
8267
|
+
if (context.resourceDir) query.resourceDir = context.resourceDir;
|
|
8268
|
+
const payload = await fetchStudioJson("/file-browser", { query });
|
|
8269
|
+
if (nonce !== fileBrowserLoadNonce) return;
|
|
8270
|
+
fileBrowserState = {
|
|
8271
|
+
rootDir: typeof payload.rootDir === "string" ? payload.rootDir : "",
|
|
8272
|
+
currentDir: typeof payload.currentDir === "string" ? payload.currentDir : "",
|
|
8273
|
+
relativeDir: typeof payload.relativeDir === "string" ? payload.relativeDir : ".",
|
|
8274
|
+
parentDir: typeof payload.parentDir === "string" ? payload.parentDir : null,
|
|
8275
|
+
entries: Array.isArray(payload.entries) ? payload.entries : [],
|
|
8276
|
+
omitted: Number(payload.omitted) || 0,
|
|
8277
|
+
omittedIgnored: Number(payload.omittedIgnored) || 0,
|
|
8278
|
+
loading: false,
|
|
8279
|
+
error: "",
|
|
8280
|
+
loaded: true,
|
|
8281
|
+
contextKey,
|
|
8282
|
+
};
|
|
8283
|
+
if (rightView === "files") {
|
|
8284
|
+
finishPreviewRender(critiqueViewEl);
|
|
8285
|
+
critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
|
|
8286
|
+
scheduleResponsePaneRepaintNudge();
|
|
8287
|
+
}
|
|
8288
|
+
if (options && options.user) setStatus("Loaded file list.", "success");
|
|
8289
|
+
} catch (error) {
|
|
8290
|
+
if (nonce !== fileBrowserLoadNonce) return;
|
|
8291
|
+
fileBrowserState = {
|
|
8292
|
+
...fileBrowserState,
|
|
8293
|
+
loading: false,
|
|
8294
|
+
error: (error && error.message) ? error.message : String(error || "Could not load files."),
|
|
8295
|
+
loaded: true,
|
|
8296
|
+
};
|
|
8297
|
+
if (rightView === "files") {
|
|
8298
|
+
finishPreviewRender(critiqueViewEl);
|
|
8299
|
+
critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
|
|
8300
|
+
scheduleResponsePaneRepaintNudge();
|
|
8301
|
+
}
|
|
8302
|
+
}
|
|
8303
|
+
}
|
|
8304
|
+
|
|
8305
|
+
async function openFileBrowserEntry(path, kind) {
|
|
8306
|
+
const context = getFileBrowserLocalLinkContext();
|
|
8307
|
+
if (kind === "text") {
|
|
8308
|
+
await openPreviewDocumentHere(path, context);
|
|
8309
|
+
return;
|
|
8310
|
+
}
|
|
8311
|
+
if (kind === "pdf") {
|
|
8312
|
+
openPreviewPdfLink(path, path, context);
|
|
8313
|
+
return;
|
|
8314
|
+
}
|
|
8315
|
+
if (kind === "image") {
|
|
8316
|
+
await openPreviewImageLink(path, path, context);
|
|
8317
|
+
return;
|
|
8318
|
+
}
|
|
8319
|
+
setStatus("No Studio preview for this file type. Use Copy path or Reveal.", "warning");
|
|
8320
|
+
}
|
|
8321
|
+
|
|
8322
|
+
async function handleFilesPaneClick(event) {
|
|
8323
|
+
if (rightView !== "files") return;
|
|
8324
|
+
const target = event.target;
|
|
8325
|
+
const actionEl = target instanceof Element ? target.closest("[data-files-action]") : null;
|
|
8326
|
+
if (!actionEl) return;
|
|
8327
|
+
event.preventDefault();
|
|
8328
|
+
const action = actionEl.getAttribute("data-files-action") || "";
|
|
8329
|
+
const path = actionEl.getAttribute("data-files-path") || "";
|
|
8330
|
+
const kind = actionEl.getAttribute("data-files-kind") || getPreviewLocalLinkKind(path);
|
|
8331
|
+
try {
|
|
8332
|
+
if (action === "parent") {
|
|
8333
|
+
if (fileBrowserState.parentDir) await loadFileBrowserDirectory(fileBrowserState.parentDir, { user: true });
|
|
8334
|
+
return;
|
|
8335
|
+
}
|
|
8336
|
+
if (action === "refresh") {
|
|
8337
|
+
await loadFileBrowserDirectory(fileBrowserState.currentDir || "", { user: true });
|
|
8338
|
+
return;
|
|
8339
|
+
}
|
|
8340
|
+
if (action === "open-dir") {
|
|
8341
|
+
await loadFileBrowserDirectory(path, { user: true });
|
|
8342
|
+
return;
|
|
8343
|
+
}
|
|
8344
|
+
if (action === "open") {
|
|
8345
|
+
await openFileBrowserEntry(path, kind);
|
|
8346
|
+
return;
|
|
8347
|
+
}
|
|
8348
|
+
if (action === "open-new") {
|
|
8349
|
+
await openPreviewDocumentInNewEditor(path, null, getFileBrowserLocalLinkContext());
|
|
8350
|
+
return;
|
|
8351
|
+
}
|
|
8352
|
+
if (action === "copy-path" || action === "copy-root") {
|
|
8353
|
+
const ok = await writeTextToClipboard(path);
|
|
8354
|
+
setStatus(ok ? "Copied path." : "Clipboard write failed.", ok ? "success" : "warning");
|
|
8355
|
+
return;
|
|
8356
|
+
}
|
|
8357
|
+
if (action === "reveal") {
|
|
8358
|
+
await revealPreviewLocalLink(path, getFileBrowserLocalLinkContext());
|
|
8359
|
+
}
|
|
8360
|
+
} catch (error) {
|
|
8361
|
+
setStatus((error && error.message) ? error.message : String(error || "File action failed."), "warning");
|
|
8362
|
+
}
|
|
8363
|
+
}
|
|
8364
|
+
|
|
8110
8365
|
function renderActiveResult() {
|
|
8111
8366
|
if (rightView === "trace") {
|
|
8112
8367
|
renderTraceView();
|
|
@@ -8118,6 +8373,11 @@
|
|
|
8118
8373
|
return;
|
|
8119
8374
|
}
|
|
8120
8375
|
|
|
8376
|
+
if (rightView === "files") {
|
|
8377
|
+
renderFilesView();
|
|
8378
|
+
return;
|
|
8379
|
+
}
|
|
8380
|
+
|
|
8121
8381
|
if (rightView === "editor-preview") {
|
|
8122
8382
|
const editorText = prepareEditorTextForPreview(sourceTextEl.value || "");
|
|
8123
8383
|
if (!editorText.trim()) {
|
|
@@ -8192,7 +8452,7 @@
|
|
|
8192
8452
|
: normalizeForCompare(sourceTextEl.value);
|
|
8193
8453
|
const responseLoaded = hasResponse && normalizedEditor === latestResponseNormalized;
|
|
8194
8454
|
const isCritiqueResponse = hasResponse && latestResponseIsStructuredCritique;
|
|
8195
|
-
const showingAuxiliaryRightPane = rightView === "trace" || rightView === "repl";
|
|
8455
|
+
const showingAuxiliaryRightPane = rightView === "trace" || rightView === "repl" || rightView === "files";
|
|
8196
8456
|
|
|
8197
8457
|
if (responseWrapEl) {
|
|
8198
8458
|
responseWrapEl.hidden = showingAuxiliaryRightPane;
|
|
@@ -8233,6 +8493,8 @@
|
|
|
8233
8493
|
: (exportingReplJournal ? "Export record" : "Export right preview");
|
|
8234
8494
|
if (rightView === "trace") {
|
|
8235
8495
|
exportPdfBtn.title = "Working view does not support preview export.";
|
|
8496
|
+
} else if (rightView === "files") {
|
|
8497
|
+
exportPdfBtn.title = "Files view does not support preview export.";
|
|
8236
8498
|
} else if (exportingReplJournal && !replJournalExportEntries.length) {
|
|
8237
8499
|
exportPdfBtn.title = "No Studio REPL record entries to export for this session yet.";
|
|
8238
8500
|
} else if (rightView === "markdown") {
|
|
@@ -8465,9 +8727,24 @@
|
|
|
8465
8727
|
return "source:" + normalized.source + ":" + normalized.label;
|
|
8466
8728
|
}
|
|
8467
8729
|
|
|
8730
|
+
function getWorkspacePersistenceStorage() {
|
|
8731
|
+
try {
|
|
8732
|
+
return window.sessionStorage || null;
|
|
8733
|
+
} catch {
|
|
8734
|
+
return null;
|
|
8735
|
+
}
|
|
8736
|
+
}
|
|
8737
|
+
|
|
8738
|
+
function clearLegacyWorkspacePersistenceStorage() {
|
|
8739
|
+
try {
|
|
8740
|
+
if (window.localStorage) window.localStorage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
|
|
8741
|
+
} catch {}
|
|
8742
|
+
}
|
|
8743
|
+
|
|
8468
8744
|
function readPersistedWorkspaceState() {
|
|
8469
8745
|
try {
|
|
8470
|
-
const
|
|
8746
|
+
const storage = getWorkspacePersistenceStorage();
|
|
8747
|
+
const raw = storage ? storage.getItem(STUDIO_WORKSPACE_STORAGE_KEY) : null;
|
|
8471
8748
|
if (!raw) return null;
|
|
8472
8749
|
const parsed = JSON.parse(raw);
|
|
8473
8750
|
if (!parsed || typeof parsed !== "object" || parsed.version !== 1) return null;
|
|
@@ -8495,7 +8772,7 @@
|
|
|
8495
8772
|
sourceState: normalizeWorkspaceSourceState(sourceState),
|
|
8496
8773
|
resourceDir: getCurrentResourceDirValue(),
|
|
8497
8774
|
editorView,
|
|
8498
|
-
rightView,
|
|
8775
|
+
rightView: isEditorOnlyMode ? "editor-preview" : rightView,
|
|
8499
8776
|
editorLanguage,
|
|
8500
8777
|
followLatest,
|
|
8501
8778
|
responseHistoryIndex,
|
|
@@ -8509,13 +8786,15 @@
|
|
|
8509
8786
|
function persistWorkspaceStateNow() {
|
|
8510
8787
|
if (!workspacePersistenceReady) return;
|
|
8511
8788
|
try {
|
|
8512
|
-
|
|
8789
|
+
const storage = getWorkspacePersistenceStorage();
|
|
8790
|
+
if (!storage) return;
|
|
8791
|
+
clearLegacyWorkspacePersistenceStorage();
|
|
8513
8792
|
const payload = buildWorkspacePersistencePayload();
|
|
8514
8793
|
if (payload.text.length > STUDIO_WORKSPACE_MAX_TEXT_CHARS) {
|
|
8515
|
-
|
|
8794
|
+
storage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
|
|
8516
8795
|
return;
|
|
8517
8796
|
}
|
|
8518
|
-
|
|
8797
|
+
storage.setItem(STUDIO_WORKSPACE_STORAGE_KEY, JSON.stringify(payload));
|
|
8519
8798
|
} catch {
|
|
8520
8799
|
// Ignore browser storage failures and quota limits.
|
|
8521
8800
|
}
|
|
@@ -8544,8 +8823,10 @@
|
|
|
8544
8823
|
workspacePersistTimer = null;
|
|
8545
8824
|
}
|
|
8546
8825
|
try {
|
|
8547
|
-
|
|
8826
|
+
const storage = getWorkspacePersistenceStorage();
|
|
8827
|
+
if (storage) storage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
|
|
8548
8828
|
} catch {}
|
|
8829
|
+
clearLegacyWorkspacePersistenceStorage();
|
|
8549
8830
|
}
|
|
8550
8831
|
|
|
8551
8832
|
function applyPersistedWorkspaceState(state) {
|
|
@@ -8563,11 +8844,15 @@
|
|
|
8563
8844
|
setEditorLanguage(state.editorLanguage.trim());
|
|
8564
8845
|
}
|
|
8565
8846
|
editorView = state.editorView === "preview" ? "preview" : "markdown";
|
|
8566
|
-
rightView =
|
|
8567
|
-
? "preview"
|
|
8568
|
-
: (state.rightView === "
|
|
8569
|
-
? "
|
|
8570
|
-
: (state.rightView === "
|
|
8847
|
+
rightView = isEditorOnlyMode
|
|
8848
|
+
? "editor-preview"
|
|
8849
|
+
: (state.rightView === "preview"
|
|
8850
|
+
? "preview"
|
|
8851
|
+
: (state.rightView === "editor-preview"
|
|
8852
|
+
? "editor-preview"
|
|
8853
|
+
: (state.rightView === "repl"
|
|
8854
|
+
? "repl"
|
|
8855
|
+
: (state.rightView === "files" ? "files" : ((state.rightView === "trace" || state.rightView === "thinking") ? "trace" : "markdown")))));
|
|
8571
8856
|
if (typeof state.followLatest === "boolean") {
|
|
8572
8857
|
followLatest = state.followLatest;
|
|
8573
8858
|
}
|
|
@@ -8592,7 +8877,7 @@
|
|
|
8592
8877
|
setStatus("Studio is busy.", "warning");
|
|
8593
8878
|
return;
|
|
8594
8879
|
}
|
|
8595
|
-
const confirmed = window.confirm("
|
|
8880
|
+
const confirmed = window.confirm("Reset the editor to a fresh blank draft in this browser tab? Saved files and responses are not changed.");
|
|
8596
8881
|
if (!confirmed) return;
|
|
8597
8882
|
const preservedResponseState = {
|
|
8598
8883
|
responseHistory: Array.isArray(responseHistory) ? responseHistory.slice() : [],
|
|
@@ -8634,7 +8919,7 @@
|
|
|
8634
8919
|
if (followSelect) followSelect.value = followLatest ? "on" : "off";
|
|
8635
8920
|
refreshResponseUi();
|
|
8636
8921
|
persistWorkspaceStateNow();
|
|
8637
|
-
setStatus("Editor
|
|
8922
|
+
setStatus("Editor reset to a fresh blank draft. Saved files and responses were not changed.", "success");
|
|
8638
8923
|
}
|
|
8639
8924
|
|
|
8640
8925
|
function setEditorText(nextText, options) {
|
|
@@ -8833,7 +9118,9 @@
|
|
|
8833
9118
|
? "editor-preview"
|
|
8834
9119
|
: (nextView === "repl"
|
|
8835
9120
|
? "repl"
|
|
8836
|
-
: (
|
|
9121
|
+
: (nextView === "files"
|
|
9122
|
+
? "files"
|
|
9123
|
+
: ((nextView === "trace" || nextView === "thinking") ? "trace" : "markdown"))));
|
|
8837
9124
|
rightViewSelect.value = rightView;
|
|
8838
9125
|
if (rightView === "trace" && previousView !== "trace") {
|
|
8839
9126
|
traceAutoScroll = true;
|
|
@@ -9173,6 +9460,11 @@
|
|
|
9173
9460
|
".diff", ".patch",
|
|
9174
9461
|
]);
|
|
9175
9462
|
const PREVIEW_LOCAL_IMAGE_LINK_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
9463
|
+
const PREVIEW_LOCAL_TEXT_LINK_FILENAMES = new Set([
|
|
9464
|
+
".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
|
|
9465
|
+
".gitignore", ".gitmodules", ".npmignore", ".prettierignore", "dockerfile", "gemfile",
|
|
9466
|
+
"justfile", "license", "makefile", "rakefile", "readme",
|
|
9467
|
+
]);
|
|
9176
9468
|
let previewLinkMenuEl = null;
|
|
9177
9469
|
let activePreviewLinkContext = null;
|
|
9178
9470
|
|
|
@@ -9220,10 +9512,17 @@
|
|
|
9220
9512
|
return match ? ("." + match[1].toLowerCase()) : "";
|
|
9221
9513
|
}
|
|
9222
9514
|
|
|
9515
|
+
function getPreviewLocalLinkFilename(href) {
|
|
9516
|
+
const path = stripPreviewLocalLinkUrlSuffix(href).replace(/\\/g, "/");
|
|
9517
|
+
const parts = path.split("/");
|
|
9518
|
+
return (parts.pop() || "").toLowerCase();
|
|
9519
|
+
}
|
|
9520
|
+
|
|
9223
9521
|
function getPreviewLocalLinkKind(href) {
|
|
9224
9522
|
const ext = getPreviewLocalLinkExtension(href);
|
|
9523
|
+
const name = getPreviewLocalLinkFilename(href);
|
|
9225
9524
|
if (ext === ".pdf") return "pdf";
|
|
9226
|
-
if (PREVIEW_LOCAL_TEXT_LINK_EXTENSIONS.has(ext)) return "text";
|
|
9525
|
+
if (PREVIEW_LOCAL_TEXT_LINK_EXTENSIONS.has(ext) || PREVIEW_LOCAL_TEXT_LINK_FILENAMES.has(name)) return "text";
|
|
9227
9526
|
if (PREVIEW_LOCAL_IMAGE_LINK_EXTENSIONS.has(ext)) return "image";
|
|
9228
9527
|
return "other";
|
|
9229
9528
|
}
|
|
@@ -9240,9 +9539,11 @@
|
|
|
9240
9539
|
function getEffectivePreviewLinkContext(contextOverride) {
|
|
9241
9540
|
const fallback = getHtmlPreviewResourceContextOptions();
|
|
9242
9541
|
const context = contextOverride && typeof contextOverride === "object" ? contextOverride : null;
|
|
9542
|
+
const hasSourcePath = Boolean(context && Object.prototype.hasOwnProperty.call(context, "sourcePath"));
|
|
9543
|
+
const hasResourceDir = Boolean(context && Object.prototype.hasOwnProperty.call(context, "resourceDir"));
|
|
9243
9544
|
return {
|
|
9244
|
-
sourcePath:
|
|
9245
|
-
resourceDir:
|
|
9545
|
+
sourcePath: hasSourcePath ? String(context.sourcePath || "") : (fallback.sourcePath || ""),
|
|
9546
|
+
resourceDir: hasResourceDir ? String(context.resourceDir || "") : (fallback.resourceDir || ""),
|
|
9246
9547
|
};
|
|
9247
9548
|
}
|
|
9248
9549
|
|
|
@@ -17891,7 +18192,7 @@
|
|
|
17891
18192
|
renderSourcePreview();
|
|
17892
18193
|
workspacePersistenceReady = true;
|
|
17893
18194
|
if (workspaceRestoredFromBrowser) {
|
|
17894
|
-
setStatus("Restored editor workspace from this browser tab. Use
|
|
18195
|
+
setStatus("Restored editor workspace from this browser tab. Use Reset editor to discard it.", "success");
|
|
17895
18196
|
}
|
|
17896
18197
|
connect();
|
|
17897
18198
|
} catch (error) {
|
package/client/studio.css
CHANGED
|
@@ -2666,6 +2666,148 @@
|
|
|
2666
2666
|
flex: 0 0 auto;
|
|
2667
2667
|
}
|
|
2668
2668
|
|
|
2669
|
+
.files-panel {
|
|
2670
|
+
display: flex;
|
|
2671
|
+
flex-direction: column;
|
|
2672
|
+
gap: 10px;
|
|
2673
|
+
color: var(--text);
|
|
2674
|
+
font-family: var(--font-ui);
|
|
2675
|
+
font-size: var(--studio-response-font-size);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
.files-toolbar {
|
|
2679
|
+
display: flex;
|
|
2680
|
+
align-items: center;
|
|
2681
|
+
justify-content: space-between;
|
|
2682
|
+
gap: 10px;
|
|
2683
|
+
flex-wrap: wrap;
|
|
2684
|
+
padding: 10px;
|
|
2685
|
+
border: 1px solid var(--border-subtle);
|
|
2686
|
+
border-radius: 10px;
|
|
2687
|
+
background: var(--panel-2);
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
.files-path-group {
|
|
2691
|
+
min-width: 0;
|
|
2692
|
+
display: flex;
|
|
2693
|
+
align-items: baseline;
|
|
2694
|
+
gap: 8px;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
.files-label {
|
|
2698
|
+
flex: 0 0 auto;
|
|
2699
|
+
font-size: 11px;
|
|
2700
|
+
font-weight: 700;
|
|
2701
|
+
letter-spacing: 0.04em;
|
|
2702
|
+
text-transform: uppercase;
|
|
2703
|
+
color: var(--studio-info-text, var(--muted));
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
.files-path,
|
|
2707
|
+
.files-subtitle span {
|
|
2708
|
+
min-width: 0;
|
|
2709
|
+
overflow: hidden;
|
|
2710
|
+
text-overflow: ellipsis;
|
|
2711
|
+
white-space: nowrap;
|
|
2712
|
+
font-family: var(--font-mono);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
.files-toolbar-actions,
|
|
2716
|
+
.files-actions {
|
|
2717
|
+
display: inline-flex;
|
|
2718
|
+
align-items: center;
|
|
2719
|
+
gap: 6px;
|
|
2720
|
+
flex-wrap: wrap;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
.files-toolbar-actions button,
|
|
2724
|
+
.files-actions button {
|
|
2725
|
+
padding: 4px 7px;
|
|
2726
|
+
font-size: 11px;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
.files-subtitle,
|
|
2730
|
+
.files-notice,
|
|
2731
|
+
.files-empty {
|
|
2732
|
+
color: var(--studio-info-text, var(--muted));
|
|
2733
|
+
font-size: 12px;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
.files-notice-error {
|
|
2737
|
+
color: var(--danger);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
.files-list {
|
|
2741
|
+
display: grid;
|
|
2742
|
+
gap: 4px;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
.files-row {
|
|
2746
|
+
display: grid;
|
|
2747
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
2748
|
+
align-items: center;
|
|
2749
|
+
gap: 8px;
|
|
2750
|
+
padding: 5px 7px;
|
|
2751
|
+
border: 1px solid var(--border-subtle);
|
|
2752
|
+
border-radius: 9px;
|
|
2753
|
+
background: var(--panel);
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
.files-row:hover {
|
|
2757
|
+
border-color: var(--control-border);
|
|
2758
|
+
background: var(--panel-2);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
.files-open-btn {
|
|
2762
|
+
display: grid;
|
|
2763
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
2764
|
+
align-items: center;
|
|
2765
|
+
gap: 8px;
|
|
2766
|
+
min-width: 0;
|
|
2767
|
+
padding: 3px 0;
|
|
2768
|
+
border: 0;
|
|
2769
|
+
background: transparent;
|
|
2770
|
+
color: var(--text);
|
|
2771
|
+
text-align: left;
|
|
2772
|
+
cursor: pointer;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
.files-open-btn:hover,
|
|
2776
|
+
.files-open-btn:focus-visible {
|
|
2777
|
+
outline: none;
|
|
2778
|
+
color: var(--accent);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
.files-icon {
|
|
2782
|
+
width: 1.3em;
|
|
2783
|
+
text-align: center;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
.files-name {
|
|
2787
|
+
min-width: 0;
|
|
2788
|
+
overflow: hidden;
|
|
2789
|
+
text-overflow: ellipsis;
|
|
2790
|
+
white-space: nowrap;
|
|
2791
|
+
font-weight: 550;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
.files-meta {
|
|
2795
|
+
color: var(--studio-info-text, var(--muted));
|
|
2796
|
+
font-size: 11px;
|
|
2797
|
+
white-space: nowrap;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
@container (max-width: 620px) {
|
|
2801
|
+
.files-row,
|
|
2802
|
+
.files-open-btn {
|
|
2803
|
+
grid-template-columns: minmax(0, 1fr);
|
|
2804
|
+
}
|
|
2805
|
+
.files-actions,
|
|
2806
|
+
.files-meta {
|
|
2807
|
+
justify-self: start;
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2669
2811
|
.trace-panel {
|
|
2670
2812
|
display: flex;
|
|
2671
2813
|
flex-direction: column;
|
package/index.ts
CHANGED
|
@@ -2442,6 +2442,16 @@ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
|
|
|
2442
2442
|
".diff", ".patch",
|
|
2443
2443
|
]);
|
|
2444
2444
|
const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
2445
|
+
const STUDIO_LOCAL_LINK_TEXT_FILENAMES = new Set([
|
|
2446
|
+
".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
|
|
2447
|
+
".gitignore", ".gitmodules", ".npmignore", ".prettierignore", "dockerfile", "gemfile",
|
|
2448
|
+
"justfile", "license", "makefile", "rakefile", "readme",
|
|
2449
|
+
]);
|
|
2450
|
+
const STUDIO_FILE_BROWSER_MAX_ENTRIES = 500;
|
|
2451
|
+
const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
|
|
2452
|
+
".git", "node_modules", ".next", ".cache", "dist", "build", "coverage", "target",
|
|
2453
|
+
"__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
2454
|
+
]);
|
|
2445
2455
|
|
|
2446
2456
|
type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "other";
|
|
2447
2457
|
|
|
@@ -2454,6 +2464,17 @@ interface StudioLocalPreviewResource {
|
|
|
2454
2464
|
resourceDir: string;
|
|
2455
2465
|
}
|
|
2456
2466
|
|
|
2467
|
+
interface StudioFileBrowserEntry {
|
|
2468
|
+
name: string;
|
|
2469
|
+
path: string;
|
|
2470
|
+
type: "directory" | "file";
|
|
2471
|
+
extension: string;
|
|
2472
|
+
kind: StudioLocalPreviewResourceKind | "directory";
|
|
2473
|
+
size: number;
|
|
2474
|
+
mtimeMs: number;
|
|
2475
|
+
hidden: boolean;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2457
2478
|
function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
|
|
2458
2479
|
const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
|
|
2459
2480
|
if (!rawPath) throw new Error("Missing PDF path.");
|
|
@@ -2522,10 +2543,11 @@ function parseStudioLocalPreviewResourcePage(resourcePath: string): number | nul
|
|
|
2522
2543
|
return null;
|
|
2523
2544
|
}
|
|
2524
2545
|
|
|
2525
|
-
function getStudioLocalPreviewResourceKind(extension: string): StudioLocalPreviewResourceKind {
|
|
2546
|
+
function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
|
|
2526
2547
|
const ext = extension.toLowerCase();
|
|
2548
|
+
const name = basename(String(filePathOrName || "")).toLowerCase();
|
|
2527
2549
|
if (ext === ".pdf") return "pdf";
|
|
2528
|
-
if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext)) return "text";
|
|
2550
|
+
if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext) || STUDIO_LOCAL_LINK_TEXT_FILENAMES.has(name)) return "text";
|
|
2529
2551
|
if (STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS.has(ext)) return "image";
|
|
2530
2552
|
return "other";
|
|
2531
2553
|
}
|
|
@@ -2563,12 +2585,94 @@ function resolveStudioLocalPreviewResourcePath(
|
|
|
2563
2585
|
filePath: candidateReal,
|
|
2564
2586
|
label: rel && rel !== "" ? rel : basename(candidateReal),
|
|
2565
2587
|
extension,
|
|
2566
|
-
kind: getStudioLocalPreviewResourceKind(extension),
|
|
2588
|
+
kind: getStudioLocalPreviewResourceKind(extension, candidateReal),
|
|
2567
2589
|
page: parseStudioLocalPreviewResourcePage(rawPath),
|
|
2568
2590
|
resourceDir: boundaryReal,
|
|
2569
2591
|
};
|
|
2570
2592
|
}
|
|
2571
2593
|
|
|
2594
|
+
function resolveStudioFileBrowserDirectory(
|
|
2595
|
+
dirPath: string | undefined,
|
|
2596
|
+
sourcePath: string | undefined,
|
|
2597
|
+
resourceDir: string | undefined,
|
|
2598
|
+
fallbackCwd: string,
|
|
2599
|
+
): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null } {
|
|
2600
|
+
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
2601
|
+
const rootReal = realpathSync(context.boundaryDir);
|
|
2602
|
+
const rawDir = typeof dirPath === "string" ? dirPath.trim() : "";
|
|
2603
|
+
const baseDir = context.baseDir;
|
|
2604
|
+
const requested = rawDir
|
|
2605
|
+
? (isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir)))
|
|
2606
|
+
? recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))
|
|
2607
|
+
: resolve(baseDir, recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))))
|
|
2608
|
+
: baseDir;
|
|
2609
|
+
const currentReal = realpathSync(requested);
|
|
2610
|
+
const currentStat = statSync(currentReal);
|
|
2611
|
+
if (!currentStat.isDirectory()) throw new Error("File browser path does not refer to a directory.");
|
|
2612
|
+
if (!isPathInsideOrEqualDirectory(currentReal, rootReal)) {
|
|
2613
|
+
throw new Error("File browser path must stay within the current Studio resource directory.");
|
|
2614
|
+
}
|
|
2615
|
+
const parent = dirname(currentReal);
|
|
2616
|
+
const parentDir = parent !== currentReal && isPathInsideOrEqualDirectory(parent, rootReal) ? parent : null;
|
|
2617
|
+
const relativeDir = relative(rootReal, currentReal) || ".";
|
|
2618
|
+
return { rootDir: rootReal, currentDir: currentReal, relativeDir, parentDir };
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
function listStudioFileBrowserDirectory(
|
|
2622
|
+
dirPath: string | undefined,
|
|
2623
|
+
sourcePath: string | undefined,
|
|
2624
|
+
resourceDir: string | undefined,
|
|
2625
|
+
fallbackCwd: string,
|
|
2626
|
+
): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number } {
|
|
2627
|
+
const context = resolveStudioFileBrowserDirectory(dirPath, sourcePath, resourceDir, fallbackCwd);
|
|
2628
|
+
const entries: StudioFileBrowserEntry[] = [];
|
|
2629
|
+
let omitted = 0;
|
|
2630
|
+
let omittedIgnored = 0;
|
|
2631
|
+
const dirents = readdirSync(context.currentDir, { withFileTypes: true });
|
|
2632
|
+
for (const dirent of dirents) {
|
|
2633
|
+
const name = dirent.name;
|
|
2634
|
+
if (STUDIO_FILE_BROWSER_IGNORED_DIRS.has(name)) {
|
|
2635
|
+
omittedIgnored += 1;
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
const candidate = join(context.currentDir, name);
|
|
2639
|
+
try {
|
|
2640
|
+
const real = realpathSync(candidate);
|
|
2641
|
+
if (!isPathInsideOrEqualDirectory(real, context.rootDir)) {
|
|
2642
|
+
omitted += 1;
|
|
2643
|
+
continue;
|
|
2644
|
+
}
|
|
2645
|
+
const stat = statSync(real);
|
|
2646
|
+
if (!stat.isDirectory() && !stat.isFile()) {
|
|
2647
|
+
omitted += 1;
|
|
2648
|
+
continue;
|
|
2649
|
+
}
|
|
2650
|
+
const type = stat.isDirectory() ? "directory" : "file";
|
|
2651
|
+
const extension = type === "file" ? extname(real).toLowerCase() : "";
|
|
2652
|
+
entries.push({
|
|
2653
|
+
name,
|
|
2654
|
+
path: real,
|
|
2655
|
+
type,
|
|
2656
|
+
extension,
|
|
2657
|
+
kind: type === "directory" ? "directory" : getStudioLocalPreviewResourceKind(extension, real),
|
|
2658
|
+
size: stat.size,
|
|
2659
|
+
mtimeMs: stat.mtimeMs,
|
|
2660
|
+
hidden: name.startsWith("."),
|
|
2661
|
+
});
|
|
2662
|
+
} catch {
|
|
2663
|
+
omitted += 1;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
entries.sort((a, b) => {
|
|
2667
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
2668
|
+
if (a.hidden !== b.hidden) return a.hidden ? 1 : -1;
|
|
2669
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
|
|
2670
|
+
});
|
|
2671
|
+
const limitedEntries = entries.slice(0, STUDIO_FILE_BROWSER_MAX_ENTRIES);
|
|
2672
|
+
omitted += Math.max(0, entries.length - limitedEntries.length);
|
|
2673
|
+
return { ...context, entries: limitedEntries, omitted, omittedIgnored };
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2572
2676
|
function resolveStudioHtmlPreviewResourcePath(
|
|
2573
2677
|
resourcePath: string | undefined,
|
|
2574
2678
|
sourcePath: string | undefined,
|
|
@@ -8958,7 +9062,7 @@ function resolveRequestedStudioDocumentFromUrl(
|
|
|
8958
9062
|
label: requestedLabel || file.label,
|
|
8959
9063
|
source: "file",
|
|
8960
9064
|
path: file.resolvedPath,
|
|
8961
|
-
resourceDir: requestedResourceDir ||
|
|
9065
|
+
resourceDir: requestedResourceDir || fallback?.resourceDir || studioCwd,
|
|
8962
9066
|
};
|
|
8963
9067
|
}
|
|
8964
9068
|
}
|
|
@@ -9314,7 +9418,7 @@ ${cssVarsBlock}
|
|
|
9314
9418
|
<button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here when no direct save path is available.">Save editor as…</button>
|
|
9315
9419
|
<button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
|
|
9316
9420
|
<button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
|
|
9317
|
-
<button id="clearWorkspaceBtn" type="button" title="Clear
|
|
9421
|
+
<button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
|
|
9318
9422
|
<label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
|
|
9319
9423
|
<button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
|
|
9320
9424
|
<button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
|
|
@@ -9508,6 +9612,7 @@ ${cssVarsBlock}
|
|
|
9508
9612
|
<option value="preview" selected>Response (Preview)</option>
|
|
9509
9613
|
<option value="editor-preview">Editor (Preview)</option>
|
|
9510
9614
|
<option value="trace">Working</option>
|
|
9615
|
+
<option value="files">Files</option>
|
|
9511
9616
|
<option value="repl">REPL</option>
|
|
9512
9617
|
</select>
|
|
9513
9618
|
</div>
|
|
@@ -12677,6 +12782,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
12677
12782
|
return;
|
|
12678
12783
|
}
|
|
12679
12784
|
|
|
12785
|
+
if (requestUrl.pathname === "/file-browser") {
|
|
12786
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
12787
|
+
if (token !== serverState.token) {
|
|
12788
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
12789
|
+
return;
|
|
12790
|
+
}
|
|
12791
|
+
|
|
12792
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
12793
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
12794
|
+
res.setHeader("Allow", "GET, HEAD");
|
|
12795
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET." });
|
|
12796
|
+
return;
|
|
12797
|
+
}
|
|
12798
|
+
|
|
12799
|
+
try {
|
|
12800
|
+
const listing = listStudioFileBrowserDirectory(
|
|
12801
|
+
requestUrl.searchParams.get("dir") ?? undefined,
|
|
12802
|
+
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
12803
|
+
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
12804
|
+
studioCwd,
|
|
12805
|
+
);
|
|
12806
|
+
respondJson(res, 200, { ok: true, ...listing, entries: method === "HEAD" ? [] : listing.entries });
|
|
12807
|
+
} catch (error) {
|
|
12808
|
+
respondJson(res, 404, { ok: false, error: `File browser unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
12809
|
+
}
|
|
12810
|
+
return;
|
|
12811
|
+
}
|
|
12812
|
+
|
|
12680
12813
|
if (requestUrl.pathname === "/local-preview-link") {
|
|
12681
12814
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
12682
12815
|
if (token !== serverState.token) {
|
|
@@ -13377,7 +13510,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13377
13510
|
label: file.label,
|
|
13378
13511
|
source: "file",
|
|
13379
13512
|
path: file.resolvedPath,
|
|
13380
|
-
resourceDir:
|
|
13513
|
+
resourceDir: ctx.cwd,
|
|
13381
13514
|
};
|
|
13382
13515
|
};
|
|
13383
13516
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.15",
|
|
4
4
|
"description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|