@khorsheed/dsh-file-preview 0.4.1 → 0.4.2
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 +7 -0
- package/README.en.md +24 -2
- package/README.i18n.yaml +2 -2
- package/README.md +24 -2
- package/lib/client.js +15165 -682
- package/lib/index.js +581 -280
- package/lib/typert.host.js +437 -84
- package/lib/typert.remote-client.d.ts +10 -2
- package/lib/typert.remote-client.js +174 -78
- package/lib/types/bash-writes.d.ts +19 -1
- package/lib/types/bash-writes.js +56 -2
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/locales.js +6 -2
- package/lib/types/client/open-in-app.js +7 -0
- package/lib/types/index.d.ts +8 -18
- package/lib/types/index.js +50 -233
- package/lib/types/read-surface.d.ts +269 -0
- package/lib/types/read-surface.js +413 -0
- package/package.json +17 -19
- package/lib/invariant.js +0 -25
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -23
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** Dictionary namespace owned by this plugin. */
|
|
3
3
|
export declare const NS = "filePreview";
|
|
4
4
|
/** The file-preview dictionary key set (the source of truth for both locales). */
|
|
5
|
-
export type FilePreviewKey = 'open' | 'view.search.placeholder' | 'view.search.noMatch' | 'preview.search.placeholder' | 'preview.search.noMatch' | 'preview.search.prev' | 'preview.search.next' | 'preview.htmlToggle' | 'preview.htmlSource' | 'preview.htmlRender' | 'preview.htmlScript' | 'preview.htmlScriptStop' | 'preview.scriptConfirm' | 'preview.scriptRun' | 'preview.scriptCancel' | 'preview.slowHint' | 'preview.fullscreen' | 'preview.exitFullscreen' | 'preview.staticHint' | 'turn.count' | 'json.copyValue' | 'json.copyJson' | 'json.copyPath' | 'json.copyPrettyJson' | 'json.copyCompactJson' | 'json.copied' | 'json.copyFailed' | 'json.collapseNode' | 'json.expandNode' | 'json.copyButtonTitle' | 'json.treeLabel' | 'markdown.copy' | 'markdown.copied' | 'markdown.footnotes' | 'diff.copy' | 'diff.copied' | 'diff.collapse' | 'diff.collapseAria' | 'diff.expand' | 'diff.expandAria' | 'diff.files' | 'diff.code' | 'diff.wrap' | 'diff.unwrap' | 'guide.title' | 'guide.description' | 'list.empty' | 'list.error' | 'list.loading' | 'list.outsideWorkspace' | 'list.refresh' | 'history.empty' | 'history.step' | 'history.step.count' | 'history.step.latest' | 'history.step.older' | 'history.step.newer' | 'detail.back' | 'mention.open' | 'action.chooseIDE' | 'action.copy' | 'action.copied' | 'action.copyPath' | 'action.
|
|
5
|
+
export type FilePreviewKey = 'open' | 'view.search.placeholder' | 'view.search.noMatch' | 'preview.search.placeholder' | 'preview.search.noMatch' | 'preview.search.prev' | 'preview.search.next' | 'preview.htmlToggle' | 'preview.htmlSource' | 'preview.htmlRender' | 'preview.htmlScript' | 'preview.htmlScriptStop' | 'preview.scriptConfirm' | 'preview.scriptRun' | 'preview.scriptCancel' | 'preview.slowHint' | 'preview.fullscreen' | 'preview.exitFullscreen' | 'preview.staticHint' | 'turn.count' | 'json.copyValue' | 'json.copyJson' | 'json.copyPath' | 'json.copyPrettyJson' | 'json.copyCompactJson' | 'json.copied' | 'json.copyFailed' | 'json.collapseNode' | 'json.expandNode' | 'json.copyButtonTitle' | 'json.treeLabel' | 'markdown.copy' | 'markdown.copied' | 'markdown.footnotes' | 'diff.copy' | 'diff.copied' | 'diff.collapse' | 'diff.collapseAria' | 'diff.expand' | 'diff.expandAria' | 'diff.files' | 'diff.code' | 'diff.wrap' | 'diff.unwrap' | 'guide.title' | 'guide.description' | 'list.empty' | 'list.error' | 'list.loading' | 'list.outsideWorkspace' | 'list.refresh' | 'history.empty' | 'history.step' | 'history.step.count' | 'history.step.latest' | 'history.step.older' | 'history.step.newer' | 'detail.back' | 'mention.open' | 'action.chooseIDE' | 'action.copy' | 'action.copied' | 'action.copyPath' | 'action.showInFolder' | 'action.openIDE' | 'action.reload' | 'action.more' | 'detail.content' | 'detail.deleted' | 'detail.diff' | 'detail.noSelection' | 'detail.preview' | 'detail.source' | 'detail.views' | 'local.binary' | 'local.noSelection' | 'local.tooLarge' | 'local.unreadable' | 'search.hit' | 'search.next' | 'search.noMatch' | 'search.placeholder' | 'search.prev' | 'state.error' | 'state.loading';
|
|
6
6
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
7
7
|
interface LocaleNamespaceMap {
|
|
8
8
|
/** The file-preview tab, document renderer, and turn card copy. */
|
|
@@ -67,15 +67,17 @@ export const zh = {
|
|
|
67
67
|
'action.copy': '复制',
|
|
68
68
|
'action.copied': '已复制',
|
|
69
69
|
'action.copyPath': '复制路径',
|
|
70
|
-
'action.
|
|
70
|
+
'action.showInFolder': '打开所在文件夹',
|
|
71
71
|
'action.openIDE': '在 IDE 中打开',
|
|
72
72
|
'action.reload': '重新加载',
|
|
73
|
+
'action.more': '更多',
|
|
73
74
|
'detail.content': '内容',
|
|
74
75
|
'detail.deleted': '文件已删除',
|
|
75
76
|
'detail.diff': '改动',
|
|
76
77
|
'detail.noSelection': '选择一个文件查看详情',
|
|
77
78
|
'detail.preview': '预览',
|
|
78
79
|
'detail.source': '源码',
|
|
80
|
+
'detail.views': '视图',
|
|
79
81
|
'local.binary': '二进制文件,无法预览文本',
|
|
80
82
|
'local.noSelection': '选择一个文件预览内容',
|
|
81
83
|
'local.tooLarge': '文件过大,未载入预览',
|
|
@@ -154,15 +156,17 @@ export const en = {
|
|
|
154
156
|
'action.copy': 'Copy',
|
|
155
157
|
'action.copied': 'Copied',
|
|
156
158
|
'action.copyPath': 'Copy path',
|
|
157
|
-
'action.
|
|
159
|
+
'action.showInFolder': 'Show in folder',
|
|
158
160
|
'action.openIDE': 'Open in IDE',
|
|
159
161
|
'action.reload': 'Reload',
|
|
162
|
+
'action.more': 'More',
|
|
160
163
|
'detail.content': 'Content',
|
|
161
164
|
'detail.deleted': 'File deleted',
|
|
162
165
|
'detail.diff': 'Changes',
|
|
163
166
|
'detail.noSelection': 'Select a file to view',
|
|
164
167
|
'detail.preview': 'Preview',
|
|
165
168
|
'detail.source': 'Source',
|
|
169
|
+
'detail.views': 'Views',
|
|
166
170
|
'local.binary': 'Binary file — text preview unavailable',
|
|
167
171
|
'local.noSelection': 'Select a file to preview',
|
|
168
172
|
'local.tooLarge': 'File too large — not loaded into preview',
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* @module @khorsheed/dsh-file-preview
|
|
15
15
|
*/
|
|
16
16
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
17
|
+
import { browserOnHost } from '@khorsheed/dsh-client-ui-panel-kit/src/client/host.ts';
|
|
17
18
|
/** GET route serving the probed application ids (official shared.ts). */
|
|
18
19
|
export const OPEN_IN_APP_APPS_ROUTE = '/open-in-app/apps';
|
|
19
20
|
/** POST route launching one application on one directory (official shared.ts). */
|
|
@@ -127,6 +128,12 @@ export class OpenInAppProbe {
|
|
|
127
128
|
}
|
|
128
129
|
async run() {
|
|
129
130
|
let apps = [];
|
|
131
|
+
// Off-host the gestures would launch on a machine the viewer is not at
|
|
132
|
+
// (see browserOnHost): publish "no apps", the existing hidden degrade.
|
|
133
|
+
if (!browserOnHost()) {
|
|
134
|
+
this.apps.set(apps);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
130
137
|
try {
|
|
131
138
|
const response = await this.fetcher(new URL(OPEN_IN_APP_APPS_ROUTE, hostBase()), {
|
|
132
139
|
headers: { accept: 'application/json' },
|
package/lib/types/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
|
6
6
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
7
7
|
import z from '@deepseek-ai/schemastery';
|
|
8
8
|
import type { FilePreviewConfig, FilePreviewList, FilePreviewOpenExternal, FilePreviewRead, FilePreviewReveal, FilePreviewTurnMap } from './types.ts';
|
|
9
|
+
export { createFilePreviewReadSurface } from './read-surface.ts';
|
|
10
|
+
export type { FilePreviewImageBytes, FilePreviewReadFs, FilePreviewReadSource, FilePreviewReadSurface } from './read-surface.ts';
|
|
9
11
|
declare module '@deepseek-ai/cordis' {
|
|
10
12
|
interface Context {
|
|
11
13
|
filePreview: FilePreviewService;
|
|
@@ -26,11 +28,10 @@ export declare class FilePreviewService extends TypertRemoteService {
|
|
|
26
28
|
/** URL prefix the image route answers under. */
|
|
27
29
|
static readonly IMAGE_ROUTE = "/file-preview-image";
|
|
28
30
|
private readonly resolved;
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
private readonly agents;
|
|
31
|
+
/** The agents service while the image route is registered; undefined
|
|
32
|
+
* otherwise (headless faces, or before `webServer`/`agents` arrive), and
|
|
33
|
+
* image reads then answer `binary` (no browser to serve). */
|
|
34
|
+
private agents;
|
|
34
35
|
/** Native reveal runner; a constructor seam for deterministic tests. */
|
|
35
36
|
private readonly revealNative;
|
|
36
37
|
/** Native open-in-app runner; a constructor seam for deterministic tests. */
|
|
@@ -41,7 +42,8 @@ export declare class FilePreviewService extends TypertRemoteService {
|
|
|
41
42
|
private readonly turnCache;
|
|
42
43
|
/**
|
|
43
44
|
* @param ctx - owning Cordis Context carrying `fs`; `webServer` and `agents`
|
|
44
|
-
* are optional (the image route
|
|
45
|
+
* are optional (the image route registers whenever both are up, headless
|
|
46
|
+
* faces lack both).
|
|
45
47
|
* @param config - optional deployment tuning; defaults cap reads and lists,
|
|
46
48
|
* and enable the bash-write collector.
|
|
47
49
|
* @param deps - test seam for the native reveal dispatch.
|
|
@@ -63,18 +65,6 @@ export declare class FilePreviewService extends TypertRemoteService {
|
|
|
63
65
|
* @param res - the response to fill with bytes or an error status.
|
|
64
66
|
*/
|
|
65
67
|
private serveImage;
|
|
66
|
-
/** Resolve the session's recorded display paths against its cwd and keep only
|
|
67
|
-
* those that currently exist as a regular file. The products surfaces fold
|
|
68
|
-
* the session log (which is history, not disk state): a path the log wrote
|
|
69
|
-
* may have been cleaned up since (a temp script the turn deleted), and such
|
|
70
|
-
* a path is not a product to list. Existence is checked fresh on every call
|
|
71
|
-
* (never cached), so a deletion that lands after a fold is visible on the
|
|
72
|
-
* next fetch. Unresolvable/unstat-able paths are treated as absent.
|
|
73
|
-
* @param agent - owning live agent; its session cwd anchors relative paths.
|
|
74
|
-
* @param paths - the display paths to probe.
|
|
75
|
-
* @returns the subset that resolves to an existing regular file.
|
|
76
|
-
*/
|
|
77
|
-
private retainExisting;
|
|
78
68
|
/**
|
|
79
69
|
* List the files one session wrote or edited: the log fold plus any
|
|
80
70
|
* bash-written files the collector verified (the S2 seam), merged so a
|
package/lib/types/index.js
CHANGED
|
@@ -38,73 +38,11 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
38
38
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
39
39
|
import z from '@deepseek-ai/schemastery';
|
|
40
40
|
import { BashWriteCollector } from './bash-writes.js';
|
|
41
|
-
import {
|
|
41
|
+
import { foldFilePreviewByTurn } from './fold.js';
|
|
42
42
|
import { openExternalNative, macAppName } from './open-external.js';
|
|
43
|
+
import { IMAGE_MIME, eventsWatermark, isImagePath, listFilePreview, readErrorMessage, readFilePreview, turnFilesFilePreview, } from './read-surface.js';
|
|
43
44
|
import { revealNativePath } from './reveal.js';
|
|
44
|
-
|
|
45
|
-
const BINARY_EXTENSIONS = new Set([
|
|
46
|
-
'.pdf', '.zip', '.gz', '.tar', '.7z', '.zstd', '.br',
|
|
47
|
-
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
48
|
-
'.wasm', '.node', '.dylib', '.so', '.dll', '.exe', '.kext',
|
|
49
|
-
'.class', '.jar', '.pyc', '.sqlite', '.db',
|
|
50
|
-
'.DS_Store', '.a', '.o',
|
|
51
|
-
]);
|
|
52
|
-
/** Image extensions served to the browser through the image route. */
|
|
53
|
-
const IMAGE_EXTENSIONS = new Set([
|
|
54
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.bmp', '.ico', '.svg',
|
|
55
|
-
]);
|
|
56
|
-
/** MIME type per image extension for the image route's Content-Type header. */
|
|
57
|
-
const IMAGE_MIME = {
|
|
58
|
-
'.png': 'image/png',
|
|
59
|
-
'.jpg': 'image/jpeg',
|
|
60
|
-
'.jpeg': 'image/jpeg',
|
|
61
|
-
'.gif': 'image/gif',
|
|
62
|
-
'.webp': 'image/webp',
|
|
63
|
-
'.avif': 'image/avif',
|
|
64
|
-
'.bmp': 'image/bmp',
|
|
65
|
-
'.ico': 'image/x-icon',
|
|
66
|
-
'.svg': 'image/svg+xml',
|
|
67
|
-
};
|
|
68
|
-
/** NUL bytes in decoded text mark a file as binary despite a successful decode. */
|
|
69
|
-
function hasNulByte(content) {
|
|
70
|
-
return content.slice(0, 4096).includes('\u0000');
|
|
71
|
-
}
|
|
72
|
-
/** Shared non-fatal UTF-8 decoder for the bounded byte-window read: invalid
|
|
73
|
-
* sequences become U+FFFD (a cut multi-byte character at the window edge
|
|
74
|
-
* included) instead of failing the read. */
|
|
75
|
-
const utf8Decoder = new TextDecoder();
|
|
76
|
-
/** Detect a binary file from its display path extension. */
|
|
77
|
-
function isBinaryPath(path) {
|
|
78
|
-
const dot = path.lastIndexOf('.');
|
|
79
|
-
return dot >= 0 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
80
|
-
}
|
|
81
|
-
/** Detect an image from its display path extension. */
|
|
82
|
-
function isImagePath(path) {
|
|
83
|
-
const dot = path.lastIndexOf('.');
|
|
84
|
-
return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
85
|
-
}
|
|
86
|
-
/** HTML extensions served through the sandboxed render channel. */
|
|
87
|
-
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
|
88
|
-
/** Detect an HTML document from its display path extension. */
|
|
89
|
-
function isHtmlPath(path) {
|
|
90
|
-
const dot = path.lastIndexOf('.');
|
|
91
|
-
return dot >= 0 && HTML_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
92
|
-
}
|
|
93
|
-
/** Best-effort scripted-HTML detection: any `<script>` tag, inline event
|
|
94
|
-
* handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
|
|
95
|
-
* Only a hint for default-mode selection and warning — never a trust
|
|
96
|
-
* decision; the sandbox and the Tier1 CSP are the real boundary. The scan
|
|
97
|
-
* runs on the read content as delivered (a truncated read may miss markers
|
|
98
|
-
* in the tail; acceptable for a hint). */
|
|
99
|
-
function isScriptedHtml(content) {
|
|
100
|
-
return /<\s*script[\s>]/i.test(content)
|
|
101
|
-
|| /\son[a-z]+\s*=/i.test(content)
|
|
102
|
-
|| /javascript:/i.test(content);
|
|
103
|
-
}
|
|
104
|
-
/** Stable error text for a failed read, without leaking backend internals. */
|
|
105
|
-
function readErrorMessage(error) {
|
|
106
|
-
return error instanceof Error ? error.message : String(error);
|
|
107
|
-
}
|
|
45
|
+
export { createFilePreviewReadSurface } from './read-surface.js';
|
|
108
46
|
/**
|
|
109
47
|
* The `filePreview` Remote service: session-scoped read-only file inspection
|
|
110
48
|
* for web surfaces. `list` is a fold over the session log, then filtered to the
|
|
@@ -150,10 +88,9 @@ let FilePreviewService = (() => {
|
|
|
150
88
|
/** URL prefix the image route answers under. */
|
|
151
89
|
static IMAGE_ROUTE = '/file-preview-image';
|
|
152
90
|
resolved = __runInitializers(this, _instanceExtraInitializers);
|
|
153
|
-
/**
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
/** The agents service when present (image route only; null headless). */
|
|
91
|
+
/** The agents service while the image route is registered; undefined
|
|
92
|
+
* otherwise (headless faces, or before `webServer`/`agents` arrive), and
|
|
93
|
+
* image reads then answer `binary` (no browser to serve). */
|
|
157
94
|
agents;
|
|
158
95
|
/** Native reveal runner; a constructor seam for deterministic tests. */
|
|
159
96
|
revealNative;
|
|
@@ -165,7 +102,8 @@ let FilePreviewService = (() => {
|
|
|
165
102
|
turnCache = new Map();
|
|
166
103
|
/**
|
|
167
104
|
* @param ctx - owning Cordis Context carrying `fs`; `webServer` and `agents`
|
|
168
|
-
* are optional (the image route
|
|
105
|
+
* are optional (the image route registers whenever both are up, headless
|
|
106
|
+
* faces lack both).
|
|
169
107
|
* @param config - optional deployment tuning; defaults cap reads and lists,
|
|
170
108
|
* and enable the bash-write collector.
|
|
171
109
|
* @param deps - test seam for the native reveal dispatch.
|
|
@@ -187,19 +125,30 @@ let FilePreviewService = (() => {
|
|
|
187
125
|
else {
|
|
188
126
|
this.collector = undefined;
|
|
189
127
|
}
|
|
190
|
-
|
|
191
|
-
this
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
128
|
+
// Optional services through a sub-fiber, never an apply-time `ctx.get`:
|
|
129
|
+
// a one-shot probe loses the route for good whenever this service applies
|
|
130
|
+
// before `webServer`/`agents` (load order is not guaranteed), and every
|
|
131
|
+
// image then reads as `binary`. The sub-fiber registers the route once
|
|
132
|
+
// both are up and withdraws it when either leaves.
|
|
133
|
+
ctx.inject(['webServer', 'agents'], (routeCtx) => {
|
|
134
|
+
const webServer = routeCtx.get('webServer');
|
|
135
|
+
const agents = routeCtx.get('agents');
|
|
136
|
+
routeCtx.effect(() => {
|
|
137
|
+
const unregister = webServer.register({
|
|
138
|
+
kind: 'prefix',
|
|
139
|
+
path: FilePreviewService.IMAGE_ROUTE,
|
|
140
|
+
/* v8 ignore next 2 -- the handler body is the request path; the
|
|
141
|
+
unit tests drive serveImage directly, the real server reaches it
|
|
142
|
+
through this registration */
|
|
143
|
+
handler: (req, res) => { void this.serveImage(req, res); },
|
|
144
|
+
});
|
|
145
|
+
this.agents = agents;
|
|
146
|
+
return () => {
|
|
147
|
+
this.agents = undefined;
|
|
148
|
+
unregister();
|
|
149
|
+
};
|
|
150
|
+
}, 'file-preview: image route');
|
|
151
|
+
});
|
|
203
152
|
}
|
|
204
153
|
/** Zero-session availability handshake for browser companions. */
|
|
205
154
|
capabilities() {
|
|
@@ -285,37 +234,6 @@ let FilePreviewService = (() => {
|
|
|
285
234
|
});
|
|
286
235
|
res.end(Buffer.from(bytes));
|
|
287
236
|
}
|
|
288
|
-
/** Resolve the session's recorded display paths against its cwd and keep only
|
|
289
|
-
* those that currently exist as a regular file. The products surfaces fold
|
|
290
|
-
* the session log (which is history, not disk state): a path the log wrote
|
|
291
|
-
* may have been cleaned up since (a temp script the turn deleted), and such
|
|
292
|
-
* a path is not a product to list. Existence is checked fresh on every call
|
|
293
|
-
* (never cached), so a deletion that lands after a fold is visible on the
|
|
294
|
-
* next fetch. Unresolvable/unstat-able paths are treated as absent.
|
|
295
|
-
* @param agent - owning live agent; its session cwd anchors relative paths.
|
|
296
|
-
* @param paths - the display paths to probe.
|
|
297
|
-
* @returns the subset that resolves to an existing regular file.
|
|
298
|
-
*/
|
|
299
|
-
async retainExisting(agent, paths) {
|
|
300
|
-
if (paths.length === 0)
|
|
301
|
-
return new Set();
|
|
302
|
-
const cwd = agent.session.header.cwd;
|
|
303
|
-
const existing = new Set();
|
|
304
|
-
for (const path of paths) {
|
|
305
|
-
if (existing.has(path))
|
|
306
|
-
continue;
|
|
307
|
-
try {
|
|
308
|
-
const target = await this.fs.resolve(path, cwd === undefined ? {} : { cwd });
|
|
309
|
-
const info = await this.fs.stat(target);
|
|
310
|
-
if (info?.type === 'file')
|
|
311
|
-
existing.add(path);
|
|
312
|
-
}
|
|
313
|
-
catch {
|
|
314
|
-
// Unresolvable or stat failed: the path is not present to list.
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
return existing;
|
|
318
|
-
}
|
|
319
237
|
/**
|
|
320
238
|
* List the files one session wrote or edited: the log fold plus any
|
|
321
239
|
* bash-written files the collector verified (the S2 seam), merged so a
|
|
@@ -326,25 +244,10 @@ let FilePreviewService = (() => {
|
|
|
326
244
|
* @returns existing first-seen files capped by `maxFiles`, with the log watermark.
|
|
327
245
|
*/
|
|
328
246
|
async list(agent) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const seen = new Set(entries.map(entry => entry.path));
|
|
334
|
-
const extra = [];
|
|
335
|
-
for (const [path, write] of captured) {
|
|
336
|
-
if (seen.has(path) || entries.length + extra.length >= this.resolved.maxFiles)
|
|
337
|
-
continue;
|
|
338
|
-
extra.push({ path, op: 'write', seq: write.seq, turn: write.turn, step: write.step, diffs: [] });
|
|
339
|
-
}
|
|
340
|
-
entries = [...entries, ...extra];
|
|
341
|
-
}
|
|
342
|
-
const existing = await this.retainExisting(agent, entries.map(entry => entry.path));
|
|
343
|
-
return {
|
|
344
|
-
entries: entries.filter(entry => existing.has(entry.path)),
|
|
345
|
-
asOfSeq: folded.asOfSeq,
|
|
346
|
-
truncated: folded.truncated || entries.length >= this.resolved.maxFiles,
|
|
347
|
-
};
|
|
247
|
+
return listFilePreview(
|
|
248
|
+
// The header read stays lazy-tolerant: the pre-core code only touched
|
|
249
|
+
// the cwd inside the existence probe, which an empty fold never reached.
|
|
250
|
+
{ fs: this.fs, cwd: agent.session.header?.cwd, maxFiles: this.resolved.maxFiles }, agent.session.snapshotEvents(), this.collector?.captured(agent.session.id));
|
|
348
251
|
}
|
|
349
252
|
/**
|
|
350
253
|
* Every turn's file mutations for the turn-tail card — the single source of
|
|
@@ -364,7 +267,7 @@ let FilePreviewService = (() => {
|
|
|
364
267
|
async turnFiles(agent) {
|
|
365
268
|
const session = agent.session;
|
|
366
269
|
const events = session.snapshotEvents();
|
|
367
|
-
const asOfSeq = events
|
|
270
|
+
const asOfSeq = eventsWatermark(events);
|
|
368
271
|
const cached = this.turnCache.get(session.id);
|
|
369
272
|
let byTurn;
|
|
370
273
|
if (cached === undefined || cached.asOfSeq !== asOfSeq) {
|
|
@@ -374,39 +277,7 @@ let FilePreviewService = (() => {
|
|
|
374
277
|
else {
|
|
375
278
|
byTurn = cached.byTurn;
|
|
376
279
|
}
|
|
377
|
-
|
|
378
|
-
const turns = [];
|
|
379
|
-
// One pass over the turn keys in ascending order (turn numbers are small).
|
|
380
|
-
const turnNumbers = new Set(byTurn.keys());
|
|
381
|
-
if (captured !== undefined) {
|
|
382
|
-
for (const write of captured.values())
|
|
383
|
-
turnNumbers.add(write.turn);
|
|
384
|
-
}
|
|
385
|
-
for (const turn of [...turnNumbers].sort((a, b) => a - b)) {
|
|
386
|
-
const files = [];
|
|
387
|
-
const map = byTurn.get(turn);
|
|
388
|
-
if (map !== undefined)
|
|
389
|
-
files.push(...map.values());
|
|
390
|
-
if (captured !== undefined) {
|
|
391
|
-
for (const [path, write] of captured) {
|
|
392
|
-
if (write.turn !== turn || files.some(file => file.path === path))
|
|
393
|
-
continue;
|
|
394
|
-
files.push({ path, seq: write.seq, step: write.step });
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
if (files.length === 0)
|
|
398
|
-
continue;
|
|
399
|
-
turns.push({ turn, files: files.sort((a, b) => a.seq - b.seq) });
|
|
400
|
-
}
|
|
401
|
-
// Filter the assembled groups to paths that still exist on disk (fresh per
|
|
402
|
-
// call — the fold cache is by log watermark, existence moves independently).
|
|
403
|
-
const existing = await this.retainExisting(agent, turns.flatMap(group => group.files.map(file => file.path)));
|
|
404
|
-
return {
|
|
405
|
-
turns: turns
|
|
406
|
-
.map(group => ({ turn: group.turn, files: group.files.filter(file => existing.has(file.path)) }))
|
|
407
|
-
.filter(group => group.files.length > 0),
|
|
408
|
-
asOfSeq,
|
|
409
|
-
};
|
|
280
|
+
return turnFilesFilePreview({ fs: this.fs, cwd: session.header?.cwd, maxFiles: this.resolved.maxFiles }, asOfSeq, byTurn, this.collector?.captured(session.id));
|
|
410
281
|
}
|
|
411
282
|
/**
|
|
412
283
|
* Read the current content of one recorded file, resolved against the
|
|
@@ -421,72 +292,18 @@ let FilePreviewService = (() => {
|
|
|
421
292
|
* browser URL, other kinds carry the size or a human-readable reason.
|
|
422
293
|
*/
|
|
423
294
|
async read(agent, path, signal) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
info = await this.fs.stat(target, signal);
|
|
438
|
-
}
|
|
439
|
-
catch (error) {
|
|
440
|
-
return { path, kind: 'error', message: readErrorMessage(error) };
|
|
441
|
-
}
|
|
442
|
-
if (info === undefined || info.type !== 'file') {
|
|
443
|
-
return { path, kind: 'missing' };
|
|
444
|
-
}
|
|
445
|
-
// HTML gets its own, wider render-channel cap; every other text read
|
|
446
|
-
// keeps `maxReadBytes`. Images keep the base cap (their route serves
|
|
447
|
-
// bytes, not the render channel).
|
|
448
|
-
const html = isHtmlPath(path);
|
|
449
|
-
const cap = html ? this.resolved.htmlMaxReadBytes : this.resolved.maxReadBytes;
|
|
450
|
-
if (info.size !== undefined && info.size > cap) {
|
|
451
|
-
return { path, kind: 'too-large', size: info.size };
|
|
452
|
-
}
|
|
453
|
-
if (isImagePath(path)) {
|
|
454
|
-
if (this.imageRoute) {
|
|
455
|
-
const encoded = encodeURIComponent(path);
|
|
456
|
-
return {
|
|
457
|
-
path,
|
|
458
|
-
kind: 'image',
|
|
459
|
-
url: `${FilePreviewService.IMAGE_ROUTE}/${agent.session.id}/${encoded}`,
|
|
460
|
-
...(info.size === undefined ? {} : { size: info.size }),
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
// No web face to serve bytes: an image is binary, never decoded as text.
|
|
464
|
-
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
465
|
-
}
|
|
466
|
-
if (isBinaryPath(path)) {
|
|
467
|
-
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
468
|
-
}
|
|
469
|
-
try {
|
|
470
|
-
// Bounded by the window, not the file: at most cap + 1 bytes are
|
|
471
|
-
// transferred (the extra byte decides `truncated`), never the whole file.
|
|
472
|
-
const bytes = await this.fs.readByteRange(target, { offset: 0, length: cap + 1 }, signal);
|
|
473
|
-
const truncated = bytes.length > cap;
|
|
474
|
-
const content = utf8Decoder.decode(truncated ? bytes.subarray(0, cap) : bytes);
|
|
475
|
-
if (hasNulByte(content)) {
|
|
476
|
-
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
477
|
-
}
|
|
478
|
-
return {
|
|
479
|
-
path,
|
|
480
|
-
kind: 'text',
|
|
481
|
-
content,
|
|
482
|
-
truncated,
|
|
483
|
-
...(html && isScriptedHtml(content) ? { htmlScripted: true } : {}),
|
|
484
|
-
...(info.size === undefined ? {} : { size: info.size }),
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
catch (error) {
|
|
488
|
-
return { path, kind: 'error', message: readErrorMessage(error) };
|
|
489
|
-
}
|
|
295
|
+
const session = agent.session;
|
|
296
|
+
return readFilePreview({
|
|
297
|
+
fs: this.fs,
|
|
298
|
+
cwd: session.header.cwd,
|
|
299
|
+
maxReadBytes: this.resolved.maxReadBytes,
|
|
300
|
+
htmlMaxReadBytes: this.resolved.htmlMaxReadBytes,
|
|
301
|
+
// The web face's image route is the local deployment's issuer; a
|
|
302
|
+
// headless composition has none and image reads answer `binary`.
|
|
303
|
+
imageUrl: this.agents !== undefined
|
|
304
|
+
? (display) => `${FilePreviewService.IMAGE_ROUTE}/${session.id}/${encodeURIComponent(display)}`
|
|
305
|
+
: undefined,
|
|
306
|
+
}, path, signal);
|
|
490
307
|
}
|
|
491
308
|
/**
|
|
492
309
|
* Reveal one recorded file in the host file manager — open its folder and
|