@deepseek-ai/dsh-api-workspace-files 0.1.5-alpha.1
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +154 -0
- package/README.zh.md +154 -0
- package/lib/client.js +522 -0
- package/lib/index.js +551 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +853 -0
- package/lib/typert.remote-client.d.ts +38 -0
- package/lib/typert.remote-client.js +287 -0
- package/lib/types/changes.d.ts +26 -0
- package/lib/types/changes.js +109 -0
- package/lib/types/client/change-feed.d.ts +105 -0
- package/lib/types/client/change-feed.js +296 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.js +26 -0
- package/lib/types/client/provider.d.ts +45 -0
- package/lib/types/client/provider.js +129 -0
- package/lib/types/client/remote.d.ts +44 -0
- package/lib/types/client/remote.js +2 -0
- package/lib/types/client/types.d.ts +65 -0
- package/lib/types/client/types.js +2 -0
- package/lib/types/index.d.ts +143 -0
- package/lib/types/index.js +368 -0
- package/lib/types/types.d.ts +163 -0
- package/lib/types/types.js +17 -0
- package/package.json +86 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The follower key of one absolute path.
|
|
3
|
+
* @param path - an absolute path from a Host stat or change frame.
|
|
4
|
+
* @returns the path with `\\` normalized to `/`.
|
|
5
|
+
*/
|
|
6
|
+
function keyOf(path) {
|
|
7
|
+
return path.replace(/\\/g, '/');
|
|
8
|
+
}
|
|
9
|
+
/** Notices of one follower, delivered in order and pulled by its consumer. */
|
|
10
|
+
class Follower {
|
|
11
|
+
address;
|
|
12
|
+
leave;
|
|
13
|
+
pending = [];
|
|
14
|
+
started = Promise.withResolvers();
|
|
15
|
+
wake;
|
|
16
|
+
ended = false;
|
|
17
|
+
hostKey;
|
|
18
|
+
/**
|
|
19
|
+
* Resolves true after the Host acknowledges its subscription, or false if
|
|
20
|
+
* this follower ends before acknowledgement.
|
|
21
|
+
*/
|
|
22
|
+
ready = this.started.promise;
|
|
23
|
+
/**
|
|
24
|
+
* @param address - resource address used for reload lookup.
|
|
25
|
+
* @param leave - unregisters this follower and its abort listener.
|
|
26
|
+
*/
|
|
27
|
+
constructor(address, leave) {
|
|
28
|
+
this.address = address;
|
|
29
|
+
this.leave = leave;
|
|
30
|
+
}
|
|
31
|
+
/** The normalized Host path, absent until a successful stat. */
|
|
32
|
+
get key() {
|
|
33
|
+
return this.hostKey;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Select the Host path for queued and future changes.
|
|
37
|
+
* @param absolutePath - the successful stat's absolute path.
|
|
38
|
+
*/
|
|
39
|
+
bind(absolutePath) {
|
|
40
|
+
this.hostKey = keyOf(absolutePath);
|
|
41
|
+
}
|
|
42
|
+
/** The Host acknowledged an active subscription and resolved workspace root. */
|
|
43
|
+
start() {
|
|
44
|
+
this.started.resolve(true);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Queue one notice.
|
|
48
|
+
* @param notice - what the consumer receives next.
|
|
49
|
+
* @param key - normalized Host path for a change; absent for a reload.
|
|
50
|
+
*/
|
|
51
|
+
push(notice, key) {
|
|
52
|
+
this.pending.push({ key, notice });
|
|
53
|
+
this.wake?.();
|
|
54
|
+
}
|
|
55
|
+
/** Deliver what is queued, then finish. */
|
|
56
|
+
end() {
|
|
57
|
+
this.ended = true;
|
|
58
|
+
this.started.resolve(false);
|
|
59
|
+
this.wake?.();
|
|
60
|
+
}
|
|
61
|
+
/** Unregister even when the consumer has not started pulling notices. */
|
|
62
|
+
dispose() {
|
|
63
|
+
this.leave();
|
|
64
|
+
}
|
|
65
|
+
/** @inheritdoc */
|
|
66
|
+
async *[Symbol.asyncIterator]() {
|
|
67
|
+
try {
|
|
68
|
+
while (true) {
|
|
69
|
+
const next = this.pending.shift();
|
|
70
|
+
if (next !== undefined) {
|
|
71
|
+
if (next.key === undefined || this.hostKey === undefined || next.key === this.hostKey)
|
|
72
|
+
yield next.notice;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (this.ended)
|
|
76
|
+
return;
|
|
77
|
+
await new Promise((resolve) => { this.wake = resolve; });
|
|
78
|
+
this.wake = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
this.dispose();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The stream and followers of one session. */
|
|
87
|
+
class SessionFeed {
|
|
88
|
+
onClose;
|
|
89
|
+
followers = new Set();
|
|
90
|
+
stream;
|
|
91
|
+
closed = false;
|
|
92
|
+
started = false;
|
|
93
|
+
/**
|
|
94
|
+
* @param remote - the Remote face carrying `workspaceFiles.changes`.
|
|
95
|
+
* @param sessionId - the session whose writes this feed follows.
|
|
96
|
+
* @param after - the previous feed of this session still closing, if any; the stream opens once it has settled.
|
|
97
|
+
* @param onClose - called once when the stream is gone, whatever the cause, with the dispose that is closing it.
|
|
98
|
+
*/
|
|
99
|
+
constructor(remote, sessionId, after, onClose) {
|
|
100
|
+
this.onClose = onClose;
|
|
101
|
+
this.stream = remote.$stream({
|
|
102
|
+
name: `workspace file changes of ${sessionId}`,
|
|
103
|
+
// A predecessor still closing finishes first, so one session never has
|
|
104
|
+
// two Host streams open at once.
|
|
105
|
+
open: (signal) => {
|
|
106
|
+
this.started = false;
|
|
107
|
+
return openAfter(after, () => remote.workspaceFiles.changes(sessionId, signal));
|
|
108
|
+
},
|
|
109
|
+
// A normal end means the Host closed the session's feed: the session is
|
|
110
|
+
// gone or the Host is shutting down, so there is nothing to reopen.
|
|
111
|
+
ended: () => new Error(`workspace file changes of ${sessionId} ended`),
|
|
112
|
+
});
|
|
113
|
+
void this.pump();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Register one resource address before its Host path is known.
|
|
117
|
+
* @param follower - receives changes and binds its path after stat.
|
|
118
|
+
*/
|
|
119
|
+
add(follower) {
|
|
120
|
+
this.followers.add(follower);
|
|
121
|
+
if (this.started)
|
|
122
|
+
follower.start();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Unregister one follower; the last one leaving disposes the stream.
|
|
126
|
+
* @param follower - the follower to drop.
|
|
127
|
+
*/
|
|
128
|
+
remove(follower) {
|
|
129
|
+
this.followers.delete(follower);
|
|
130
|
+
if (this.followers.size === 0)
|
|
131
|
+
this.close();
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Reload an address and every follower bound to the same Host path.
|
|
135
|
+
* @param address - the resource address requesting a reload.
|
|
136
|
+
*/
|
|
137
|
+
requestRestat(address) {
|
|
138
|
+
const keys = new Set();
|
|
139
|
+
for (const follower of this.followers) {
|
|
140
|
+
if (follower.address === address && follower.key !== undefined)
|
|
141
|
+
keys.add(follower.key);
|
|
142
|
+
}
|
|
143
|
+
for (const follower of this.followers) {
|
|
144
|
+
if (follower.address === address || (follower.key !== undefined && keys.has(follower.key))) {
|
|
145
|
+
follower.push({ kind: 'restat' });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async pump() {
|
|
150
|
+
try {
|
|
151
|
+
for await (const item of this.stream) {
|
|
152
|
+
const frame = item.value;
|
|
153
|
+
switch (frame.kind) {
|
|
154
|
+
case 'ready':
|
|
155
|
+
item.accept();
|
|
156
|
+
this.started = true;
|
|
157
|
+
for (const follower of this.followers)
|
|
158
|
+
follower.start();
|
|
159
|
+
break;
|
|
160
|
+
case 'change': {
|
|
161
|
+
const key = keyOf(frame.change.absolutePath);
|
|
162
|
+
const notice = editOf(frame.change);
|
|
163
|
+
for (const follower of this.followers)
|
|
164
|
+
follower.push(notice, key);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
default:
|
|
168
|
+
assertNever(frame);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// A terminal stream failure or the Host's end: followers end quietly
|
|
174
|
+
// below, and the metadata they hold stays the last known.
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
this.close();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
close() {
|
|
181
|
+
if (this.closed)
|
|
182
|
+
return;
|
|
183
|
+
this.closed = true;
|
|
184
|
+
const closed = this.stream.dispose();
|
|
185
|
+
for (const follower of this.followers)
|
|
186
|
+
follower.end();
|
|
187
|
+
this.followers.clear();
|
|
188
|
+
this.onClose(closed);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Open a Host stream once a predecessor has finished closing.
|
|
193
|
+
* @param after - the predecessor's dispose, or nothing to wait for.
|
|
194
|
+
* @param open - opens the stream.
|
|
195
|
+
* @returns the stream's items.
|
|
196
|
+
*/
|
|
197
|
+
async function* openAfter(after, open) {
|
|
198
|
+
await after;
|
|
199
|
+
yield* open();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The write one Host frame reports.
|
|
203
|
+
* @param frame - the Host frame.
|
|
204
|
+
* @returns the edit notice followers receive.
|
|
205
|
+
*/
|
|
206
|
+
function editOf(frame) {
|
|
207
|
+
return 'absent' in frame ? { kind: 'absent' } : { kind: 'changed', version: frame.version };
|
|
208
|
+
}
|
|
209
|
+
function assertNever(frame) {
|
|
210
|
+
throw new Error(`Unexpected workspace file watch frame: ${JSON.stringify(frame)}`);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Per-session fan-out of the Host's workspace file change stream.
|
|
214
|
+
*
|
|
215
|
+
* Owned by the provider; one instance serves every session of the Client.
|
|
216
|
+
*/
|
|
217
|
+
export class ChangeFeed {
|
|
218
|
+
remote;
|
|
219
|
+
/** Live feeds only: a feed removes itself when its stream closes. */
|
|
220
|
+
sessions = new Map();
|
|
221
|
+
/** Streams still closing, by session: the session's next feed opens after its predecessor has settled. */
|
|
222
|
+
closing = new Map();
|
|
223
|
+
/**
|
|
224
|
+
* @param remote - the Remote face carrying `$stream` and `workspaceFiles.changes`.
|
|
225
|
+
*/
|
|
226
|
+
constructor(remote) {
|
|
227
|
+
this.remote = remote;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Follow one resource address in one session before its Host path is known.
|
|
231
|
+
*
|
|
232
|
+
* The follower is registered on call, not on first pull. Changes delivered
|
|
233
|
+
* to this Client are queued while stat is pending. The first follower starts
|
|
234
|
+
* the session's local `changes` call. The iterable ends
|
|
235
|
+
* when `signal` aborts or when the session stream is gone; ending it early
|
|
236
|
+
* (`break`, `return`) unregisters the follower as well, and the last follower
|
|
237
|
+
* of a session disposes its stream. Await a true `ready` result before stat
|
|
238
|
+
* so the Host subscription is active, then bind each stat's absolute path. Until binding,
|
|
239
|
+
* any session write can trigger a retry; after binding, only matching queued
|
|
240
|
+
* and live changes pass.
|
|
241
|
+
* @param sessionId - the session whose workspace holds the file.
|
|
242
|
+
* @param address - the resource address, used only for reload lookup.
|
|
243
|
+
* @param signal - ends the follow.
|
|
244
|
+
* @returns a single-consumer subscription with Host-path binding and explicit disposal.
|
|
245
|
+
*/
|
|
246
|
+
follow(sessionId, address, signal) {
|
|
247
|
+
const feed = signal.aborted ? undefined : this.feedOf(sessionId);
|
|
248
|
+
const leave = () => {
|
|
249
|
+
signal.removeEventListener('abort', leave);
|
|
250
|
+
follower.end();
|
|
251
|
+
feed?.remove(follower);
|
|
252
|
+
};
|
|
253
|
+
const follower = new Follower(address, leave);
|
|
254
|
+
if (feed === undefined) {
|
|
255
|
+
follower.end();
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
feed.add(follower);
|
|
259
|
+
signal.addEventListener('abort', leave, { once: true });
|
|
260
|
+
}
|
|
261
|
+
return follower;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Ask an address and its same-session Host-path peers to `stat` again.
|
|
265
|
+
* @param sessionId - the session whose workspace holds the file.
|
|
266
|
+
* @param address - the resource address requesting a reload.
|
|
267
|
+
*/
|
|
268
|
+
requestRestat(sessionId, address) {
|
|
269
|
+
this.sessions.get(sessionId)?.requestRestat(address);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Wait for every stream that is still closing, so an owner tearing down
|
|
273
|
+
* leaves no Host stream behind.
|
|
274
|
+
* @returns resolves once no stream of this feed is closing.
|
|
275
|
+
*/
|
|
276
|
+
async settle() {
|
|
277
|
+
await Promise.all(this.closing.values());
|
|
278
|
+
}
|
|
279
|
+
feedOf(sessionId) {
|
|
280
|
+
const existing = this.sessions.get(sessionId);
|
|
281
|
+
if (existing !== undefined)
|
|
282
|
+
return existing;
|
|
283
|
+
const feed = new SessionFeed(this.remote, sessionId, this.closing.get(sessionId), (closed) => {
|
|
284
|
+
this.sessions.delete(sessionId);
|
|
285
|
+
// A dispose that rejects is still a settled close: nothing remains to wait for.
|
|
286
|
+
const tracked = closed.then(() => undefined, () => undefined).then(() => {
|
|
287
|
+
if (this.closing.get(sessionId) === tracked)
|
|
288
|
+
this.closing.delete(sessionId);
|
|
289
|
+
});
|
|
290
|
+
this.closing.set(sessionId, tracked);
|
|
291
|
+
});
|
|
292
|
+
this.sessions.set(sessionId, feed);
|
|
293
|
+
return feed;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
//# sourceMappingURL=change-feed.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half: the `file` resource provider over `remote.workspaceFiles`.
|
|
3
|
+
*
|
|
4
|
+
* `types.ts` is what the protocol publishes, `change-feed.ts` shares one Host
|
|
5
|
+
* `changes` stream per session, `provider.ts` turns it and `stat` into a value
|
|
6
|
+
* stream, and this module only wires them into `ctx.resources`.
|
|
7
|
+
*/
|
|
8
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis';
|
|
9
|
+
export type { SessionLookup } from './provider.ts';
|
|
10
|
+
export type { WorkspaceFileParams, WorkspaceFileResource } from './types.ts';
|
|
11
|
+
/** Required browser services: the resource model, the Remote carrier and its namespace, and the Session list. */
|
|
12
|
+
export declare const inject: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Client plugin body: register the `file` provider for this plugin's lifetime.
|
|
15
|
+
* @param ctx - client root context carrying `resources`, the Remote face, and `sessions`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function apply(ctx: ClientContext): void;
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ChangeFeed } from "./change-feed.js";
|
|
2
|
+
import { createFileResourceProvider } from "./provider.js";
|
|
3
|
+
/** Required browser services: the resource model, the Remote carrier and its namespace, and the Session list. */
|
|
4
|
+
export const inject = ['resources', 'remote', 'remote.workspaceFiles', 'sessions'];
|
|
5
|
+
/**
|
|
6
|
+
* Client plugin body: register the `file` provider for this plugin's lifetime.
|
|
7
|
+
* @param ctx - client root context carrying `resources`, the Remote face, and `sessions`.
|
|
8
|
+
*/
|
|
9
|
+
export function apply(ctx) {
|
|
10
|
+
// The current Session changes with navigation; absolute addresses read it on demand.
|
|
11
|
+
const sessions = {
|
|
12
|
+
current: () => ctx.sessions.list.getSnapshot().current,
|
|
13
|
+
};
|
|
14
|
+
const changes = new ChangeFeed(ctx.remote);
|
|
15
|
+
const provider = createFileResourceProvider(ctx.remote, changes, sessions);
|
|
16
|
+
ctx.effect(() => {
|
|
17
|
+
const release = ctx.resources.register(provider);
|
|
18
|
+
// Teardown waits for every session stream still closing, so the plugin
|
|
19
|
+
// leaves no Host stream behind.
|
|
20
|
+
return async () => {
|
|
21
|
+
release();
|
|
22
|
+
await changes.settle();
|
|
23
|
+
};
|
|
24
|
+
}, 'workspace-files: file resource provider');
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `file` protocol's provider: a workspace file's metadata as a stream of
|
|
3
|
+
* `RemoteResult` frames.
|
|
4
|
+
*
|
|
5
|
+
* An address names the file in one of two scopes. A `session` address,
|
|
6
|
+
* `dsh-resource://file/session/<sessionId>/<path>`, carries a path relative to
|
|
7
|
+
* that Session's workspace root: the Host receives the relative path as-is and
|
|
8
|
+
* resolves it against the root it holds. Only the Host's `stat.absolutePath`
|
|
9
|
+
* selects the change-feed key; no Client Session summary is needed.
|
|
10
|
+
* An `absolute` address, `dsh-resource://file/absolute/<path>`, carries no
|
|
11
|
+
* Session and is read through the Session on screen. An address neither scope
|
|
12
|
+
* resolves yields one failure frame — `workspace-file/unsupported-address` for
|
|
13
|
+
* a string outside the grammar, `workspace-file/unknown-workspace` when the
|
|
14
|
+
* absolute address has no current Session — and ends.
|
|
15
|
+
*
|
|
16
|
+
* The first frame is the file's `stat`; every Host-reported write yields the
|
|
17
|
+
* metadata flagged `changed`; a reported disappearance, or a write while the
|
|
18
|
+
* last stat had failed, runs `stat` again and flags what it finds; a reload
|
|
19
|
+
* runs `stat` again and clears the flag. Failures travel as `ok: false` frames, never as thrown errors: the
|
|
20
|
+
* Remote face does not reject, and anything thrown inside the stream is a
|
|
21
|
+
* programming error the resource model lets surface. A failed stat does not end
|
|
22
|
+
* the stream: the next write or reload stats again. One {@link ChangeFeed}
|
|
23
|
+
* serves every open file of the Client.
|
|
24
|
+
*/
|
|
25
|
+
import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client';
|
|
26
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
27
|
+
import type { ChangeFeed } from './change-feed.ts';
|
|
28
|
+
import type { WorkspaceFilesRemote } from './remote.ts';
|
|
29
|
+
/** The current Session used to authorize an absolute address. */
|
|
30
|
+
export interface SessionLookup {
|
|
31
|
+
/**
|
|
32
|
+
* The Session on screen, which an `absolute` address is read through.
|
|
33
|
+
* @returns its id, or `undefined` while no Session is current.
|
|
34
|
+
*/
|
|
35
|
+
current(): SessionId | undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the `file` provider over one Remote face, one change feed, and the Client's Session list.
|
|
39
|
+
* @param remote - the Remote face carrying `workspaceFiles.stat`.
|
|
40
|
+
* @param changes - the per-session change fan-out.
|
|
41
|
+
* @param sessions - the current Session, read for absolute addresses on every open and reload.
|
|
42
|
+
* @returns the provider to register into `ctx.resources`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createFileResourceProvider(remote: WorkspaceFilesRemote, changes: ChangeFeed, sessions: SessionLookup): ResourceProvider<'file'>;
|
|
45
|
+
//# sourceMappingURL=provider.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol';
|
|
2
|
+
import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path';
|
|
3
|
+
/**
|
|
4
|
+
* Build the `file` provider over one Remote face, one change feed, and the Client's Session list.
|
|
5
|
+
* @param remote - the Remote face carrying `workspaceFiles.stat`.
|
|
6
|
+
* @param changes - the per-session change fan-out.
|
|
7
|
+
* @param sessions - the current Session, read for absolute addresses on every open and reload.
|
|
8
|
+
* @returns the provider to register into `ctx.resources`.
|
|
9
|
+
*/
|
|
10
|
+
export function createFileResourceProvider(remote, changes, sessions) {
|
|
11
|
+
return {
|
|
12
|
+
protocol: 'file',
|
|
13
|
+
async *open(address, { signal }) {
|
|
14
|
+
const resolved = resolve(address, sessions);
|
|
15
|
+
if (!resolved.ok) {
|
|
16
|
+
yield resolved;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const { sessionId, path } = resolved.value;
|
|
20
|
+
// Queue changes delivered to this Client while stat is pending.
|
|
21
|
+
const notices = changes.follow(sessionId, address, signal);
|
|
22
|
+
const stat = () => remote.workspaceFiles.stat(sessionId, path, signal);
|
|
23
|
+
// Read through a call: a plain `signal.aborted` is narrowed to `false` by
|
|
24
|
+
// the first check and would read as always-false after the later awaits.
|
|
25
|
+
const aborted = () => signal.aborted;
|
|
26
|
+
// Undefined while the last stat failed: the follow is on the address, not
|
|
27
|
+
// on the file, so a write or a reload can still bring the file live.
|
|
28
|
+
let current;
|
|
29
|
+
try {
|
|
30
|
+
if (!await notices.ready || aborted())
|
|
31
|
+
return;
|
|
32
|
+
const first = await stat();
|
|
33
|
+
if (aborted())
|
|
34
|
+
return;
|
|
35
|
+
if (first.ok) {
|
|
36
|
+
notices.bind(first.value.absolutePath);
|
|
37
|
+
current = metadataOf(first.value, false);
|
|
38
|
+
yield { ok: true, value: current };
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
yield first;
|
|
42
|
+
}
|
|
43
|
+
for await (const notice of notices) {
|
|
44
|
+
if (current === undefined) {
|
|
45
|
+
// Still gone: nothing new to report.
|
|
46
|
+
if (notice.kind === 'absent')
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
else if (notice.kind === 'changed') {
|
|
50
|
+
// Frames report observations: holding this version already means the
|
|
51
|
+
// consumer learns nothing new.
|
|
52
|
+
if (notice.version === current.version)
|
|
53
|
+
continue;
|
|
54
|
+
current = { ...current, version: notice.version, changed: true };
|
|
55
|
+
yield { ok: true, value: current };
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// A Host notice may mean stale content; only a reload clears the flag.
|
|
59
|
+
const again = await stat();
|
|
60
|
+
if (aborted())
|
|
61
|
+
return;
|
|
62
|
+
if (!again.ok) {
|
|
63
|
+
current = undefined;
|
|
64
|
+
yield again;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
notices.bind(again.value.absolutePath);
|
|
68
|
+
current = metadataOf(again.value, notice.kind !== 'restat');
|
|
69
|
+
yield { ok: true, value: current };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
notices.dispose();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
reload(address) {
|
|
77
|
+
const resolved = resolve(address, sessions);
|
|
78
|
+
if (resolved.ok)
|
|
79
|
+
changes.requestRestat(resolved.value.sessionId, address);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve one address to the Host call it stands for, or to the failure frame it earns.
|
|
85
|
+
* @param address - the full address, scheme included.
|
|
86
|
+
* @param sessions - the Client's Session list.
|
|
87
|
+
* @returns the Host file, or the `unsupported-address` / `unknown-workspace` failure.
|
|
88
|
+
*/
|
|
89
|
+
function resolve(address, sessions) {
|
|
90
|
+
const parsed = parseFileAddress(address);
|
|
91
|
+
if (parsed === undefined)
|
|
92
|
+
return { ok: false, error: unsupportedAddress(address) };
|
|
93
|
+
if (parsed.scope === 'session') {
|
|
94
|
+
// The address is a string boundary: its id segment is the Session id it names.
|
|
95
|
+
const sessionId = parsed.sessionId;
|
|
96
|
+
return { ok: true, value: { sessionId, path: parsed.path } };
|
|
97
|
+
}
|
|
98
|
+
const sessionId = sessions.current();
|
|
99
|
+
if (sessionId === undefined)
|
|
100
|
+
return { ok: false, error: unknownWorkspace(address) };
|
|
101
|
+
return { ok: true, value: { sessionId, path: parsed.path } };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The failure frame's error for an address this provider does not serve.
|
|
105
|
+
* @param address - the offending address.
|
|
106
|
+
* @returns the typed error.
|
|
107
|
+
*/
|
|
108
|
+
function unsupportedAddress(address) {
|
|
109
|
+
return new RemoteError('workspace-file/unsupported-address', `${address} is not a dsh-resource://file/session/<sessionId>/<path> or dsh-resource://file/absolute/<path> address`, { address });
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The failure frame's error for an absolute address with no current Session.
|
|
113
|
+
* @param address - the offending address.
|
|
114
|
+
* @returns the typed error.
|
|
115
|
+
*/
|
|
116
|
+
function unknownWorkspace(address) {
|
|
117
|
+
return new RemoteError('workspace-file/unknown-workspace', `${address} requires a current Session`, { address });
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The resource value one `stat` result amounts to.
|
|
121
|
+
* @param stat - what the Host reported.
|
|
122
|
+
* @param changed - whether the consumer's content may be stale: `true` after a
|
|
123
|
+
* Host notice prompted the stat, `false` for the opening stat and a reload's.
|
|
124
|
+
* @returns the metadata frame value.
|
|
125
|
+
*/
|
|
126
|
+
function metadataOf(stat, changed) {
|
|
127
|
+
return { absolutePath: stat.absolutePath, version: stat.version, changed, ...(stat.bytes === undefined ? {} : { bytes: stat.bytes }) };
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=provider.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the Client Remote this package calls: the generated
|
|
3
|
+
* `workspaceFiles` methods by name, and the stream supervisor structurally, so
|
|
4
|
+
* the feed and the provider are testable against a scripted face.
|
|
5
|
+
*/
|
|
6
|
+
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client';
|
|
7
|
+
/** One item of a supervised stream; `accept` marks the delivering generation as healthy. */
|
|
8
|
+
export interface SupervisedStreamItem<Item> {
|
|
9
|
+
/** The decoded frame. */
|
|
10
|
+
readonly value: Item;
|
|
11
|
+
/** Reset the reconnect backoff: this generation is delivering. */
|
|
12
|
+
accept(): void;
|
|
13
|
+
}
|
|
14
|
+
/** A reconnecting single-consumer stream the Remote supervises. */
|
|
15
|
+
export interface SupervisedStream<Item> extends AsyncIterable<SupervisedStreamItem<Item>> {
|
|
16
|
+
/**
|
|
17
|
+
* Stop the stream for good.
|
|
18
|
+
* @returns once the active generation and the consumer iterator are closed.
|
|
19
|
+
*/
|
|
20
|
+
dispose(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/** What one supervised stream needs from its owner. */
|
|
23
|
+
export interface SupervisedStreamOptions<Item> {
|
|
24
|
+
/** Diagnostic owner name. */
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/** Open one physical generation; `signal` aborts it. */
|
|
27
|
+
readonly open: (signal: AbortSignal) => AsyncIterable<Item>;
|
|
28
|
+
/** The error a generation's normal end amounts to; a carrier error asks for a reopen, anything else is terminal. */
|
|
29
|
+
readonly ended: (accepted: boolean) => Error;
|
|
30
|
+
}
|
|
31
|
+
/** The `workspaceFiles` namespace methods this package calls, as the generated Remote declares them. */
|
|
32
|
+
export type WorkspaceFilesNamespace = Pick<ClientRemote['workspaceFiles'], 'stat' | 'changes'>;
|
|
33
|
+
/** The Client Remote as this package sees it. */
|
|
34
|
+
export interface WorkspaceFilesRemote {
|
|
35
|
+
/**
|
|
36
|
+
* Create one reconnecting stream.
|
|
37
|
+
* @param options - opener and end classification.
|
|
38
|
+
* @returns the supervised stream, unstarted until iterated.
|
|
39
|
+
*/
|
|
40
|
+
$stream<Item>(options: SupervisedStreamOptions<Item>): SupervisedStream<Item>;
|
|
41
|
+
/** The `workspaceFiles` namespace. */
|
|
42
|
+
readonly workspaceFiles: WorkspaceFilesNamespace;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=remote.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
2
|
+
interface ResourceProtocolMap {
|
|
3
|
+
/**
|
|
4
|
+
* One workspace file's metadata, addressed as
|
|
5
|
+
* `dsh-resource://file/session/<sessionId>/<workspace-relative path>` or
|
|
6
|
+
* `dsh-resource://file/absolute/<absolute path>`.
|
|
7
|
+
*/
|
|
8
|
+
file: WorkspaceFileResource;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** What a `file` tab is asked to reveal on open or navigation; JSON-shaped. */
|
|
12
|
+
export interface WorkspaceFileParams {
|
|
13
|
+
/** 1-based line to scroll into view; absent leaves the position alone. */
|
|
14
|
+
readonly line?: number;
|
|
15
|
+
}
|
|
16
|
+
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
17
|
+
interface RemoteErrorDetailsMap {
|
|
18
|
+
/**
|
|
19
|
+
* The address is not a `dsh-resource://file/` address in a scope the
|
|
20
|
+
* provider serves: `session/<sessionId>/<workspace-relative path>` or
|
|
21
|
+
* `absolute/<absolute path>`. Raised by the Client provider; the Host never
|
|
22
|
+
* emits it.
|
|
23
|
+
*/
|
|
24
|
+
'workspace-file/unsupported-address': {
|
|
25
|
+
readonly address: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* An `absolute` address has no current Session to authorize its Host call.
|
|
29
|
+
* Raised by the Client provider; the Host never emits it. Session addresses
|
|
30
|
+
* are resolved by the Host without a Client Session summary.
|
|
31
|
+
*/
|
|
32
|
+
'workspace-file/unknown-workspace': {
|
|
33
|
+
readonly address: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One workspace file as the resource model carries it: metadata only.
|
|
39
|
+
*
|
|
40
|
+
* The stream reports that the file moved on; it never carries content. A
|
|
41
|
+
* consumer reads the text itself, by page, and uses `version` and `changed` to
|
|
42
|
+
* know when its pages are stale.
|
|
43
|
+
*/
|
|
44
|
+
export interface WorkspaceFileResource {
|
|
45
|
+
/** Absolute path in the Host filesystem, as returned by the last successful stat. */
|
|
46
|
+
readonly absolutePath: string;
|
|
47
|
+
/** The Host's latest report of the file's version: from `stat` first, then from each reported write. */
|
|
48
|
+
readonly version: string;
|
|
49
|
+
/** Byte size as of the last `stat`, when the backend reports it. */
|
|
50
|
+
readonly bytes?: number;
|
|
51
|
+
/** The Host reported a write after the last `stat`; a reload (`stat` again) clears it. */
|
|
52
|
+
readonly changed: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** One Host-reported write inside the session's workspace. */
|
|
55
|
+
export type WorkspaceFileEdit = {
|
|
56
|
+
readonly kind: 'changed';
|
|
57
|
+
readonly version: string;
|
|
58
|
+
} | {
|
|
59
|
+
readonly kind: 'absent';
|
|
60
|
+
};
|
|
61
|
+
/** What one follower of a path receives: a Host write, or a local request to `stat` again. */
|
|
62
|
+
export type WorkspaceFileNotice = WorkspaceFileEdit | {
|
|
63
|
+
readonly kind: 'restat';
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=types.d.ts.map
|