@hachej/boring-ui-kit 0.4.50 → 0.4.52
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/dist/chat-react-controller.d.ts +4 -1
- package/dist/chat-react-controller.js +4 -0
- package/dist/chat-react-remote.d.ts +16 -1
- package/dist/chat-react-remote.js +69 -10
- package/dist/chat-react-session.d.ts +2 -3
- package/dist/chat-react-session.js +38 -4
- package/dist/file-tree-view.js +37 -23
- package/dist/file-tree.d.ts +12 -0
- package/dist/file-tree.js +56 -4
- package/dist/native-chat.d.ts +8 -1
- package/dist/native-chat.js +45 -8
- package/dist/remote-chat.js +51 -5
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +11 -11
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +12 -12
- package/r/drawing-viewer.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +2 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +8 -8
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +11 -11
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +8 -8
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +1 -1
- package/r/registry.json +162 -162
- package/r/shared-chat.json +6 -6
- package/r/spaces.json +1 -1
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +4 -4
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export type ConversationControllerState<C> =
|
|
1
|
+
export type ConversationControllerState<C> =
|
|
2
|
+
/** `controller`: created, its `connect` still running (absent before creation). Hosts that wait for `ready` can ignore it. */
|
|
3
|
+
{
|
|
2
4
|
readonly status: 'connecting';
|
|
5
|
+
readonly controller?: C;
|
|
3
6
|
} | {
|
|
4
7
|
readonly status: 'offline';
|
|
5
8
|
readonly error: unknown;
|
|
@@ -34,6 +34,7 @@ export function useControllerPerConversation(options) {
|
|
|
34
34
|
await retire();
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
|
+
publish({ status: 'connecting', controller: owned });
|
|
37
38
|
await connect?.(owned);
|
|
38
39
|
if (off) {
|
|
39
40
|
await retire();
|
|
@@ -43,6 +44,9 @@ export function useControllerPerConversation(options) {
|
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
45
46
|
catch (error) {
|
|
47
|
+
// A controller handed out while connecting is withdrawn before it is disposed, so no host holds a disposed one.
|
|
48
|
+
if (owned)
|
|
49
|
+
publish({ status: 'connecting' });
|
|
46
50
|
try {
|
|
47
51
|
await retire();
|
|
48
52
|
}
|
|
@@ -4,18 +4,32 @@ export type ChatPlacement = 'docked' | 'floating';
|
|
|
4
4
|
/** Reports the placement of the chat showing `controller` while the caller is mounted. */
|
|
5
5
|
export declare function useChatPlacement(controller: ChatController, placement: ChatPlacement): void;
|
|
6
6
|
export declare function readChatPlacement(controller: ChatController | undefined): ChatPlacement | undefined;
|
|
7
|
+
type RemoteActions = Partial<Pick<RemoteChat, 'answer' | 'withdraw'>>;
|
|
8
|
+
/**
|
|
9
|
+
* `connecting` carries the controller as soon as it exists (D-1434, before the host's first view lands): its composer takes messages at
|
|
10
|
+
* once, shows them as the person's own and sends them, in order, once connected; the transcript appears with the first view. `actions` and
|
|
11
|
+
* `configure` wait for the connection. `ready` still means the first view landed, with the same controller; a host that waits for `ready`
|
|
12
|
+
* (or ignores `controller` while connecting) behaves as before. If connecting fails the state turns `offline` without a controller, and
|
|
13
|
+
* the messages not sent come back in the next attempt's composer.
|
|
14
|
+
*/
|
|
7
15
|
export type RemoteChatState = {
|
|
8
16
|
readonly status: 'connecting';
|
|
9
17
|
readonly conversationId: string | undefined;
|
|
18
|
+
readonly controller?: NativeChatController;
|
|
19
|
+
readonly actions?: RemoteActions;
|
|
20
|
+
readonly configure?: RemoteChat['configure'];
|
|
10
21
|
} | {
|
|
11
22
|
readonly status: 'offline';
|
|
12
23
|
readonly conversationId: string | undefined;
|
|
13
24
|
readonly error: string;
|
|
25
|
+
readonly controller?: undefined;
|
|
26
|
+
readonly actions?: undefined;
|
|
27
|
+
readonly configure?: undefined;
|
|
14
28
|
} | {
|
|
15
29
|
readonly status: 'ready';
|
|
16
30
|
readonly conversationId: string;
|
|
17
31
|
readonly controller: NativeChatController;
|
|
18
|
-
readonly actions:
|
|
32
|
+
readonly actions: RemoteActions;
|
|
19
33
|
readonly configure: RemoteChat['configure'];
|
|
20
34
|
};
|
|
21
35
|
export interface RemoteChatHookOptions {
|
|
@@ -30,4 +44,5 @@ export interface RemoteChatHookOptions {
|
|
|
30
44
|
}
|
|
31
45
|
/** One owned native controller per selection; the remote transport itself reopens dropped streams. Failed acquisition retries each second. */
|
|
32
46
|
export declare function useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext, transport, webSocket }: RemoteChatHookOptions): RemoteChatState;
|
|
47
|
+
export {};
|
|
33
48
|
//# sourceMappingURL=chat-react-remote.d.ts.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useLayoutEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
|
|
3
4
|
import { createNativeChatController } from './native-chat.js';
|
|
4
5
|
import { createRemoteChat } from './remote-chat.js';
|
|
5
6
|
import { useControllerPerConversation } from './chat-react-controller.js';
|
|
@@ -24,6 +25,34 @@ export function useChatPlacement(controller, placement) {
|
|
|
24
25
|
export function readChatPlacement(controller) {
|
|
25
26
|
return controller ? placements.get(controller)?.at(-1)?.placement : undefined;
|
|
26
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The conversation and link the controller borrows while the remote chat opens. Each member waits for the remote and then forwards
|
|
30
|
+
* to it unchanged; the identity is the selection until the server's view names it (the controller reads it again on connect).
|
|
31
|
+
*/
|
|
32
|
+
function pendingConversation(pending, selection) {
|
|
33
|
+
const forward = (name) => (async (...args) => (await pending.remote).conversation[name](...args));
|
|
34
|
+
const conversation = {
|
|
35
|
+
get id() { return (pending.current?.conversation.id ?? selection); },
|
|
36
|
+
watch: forward('watch'), submit: forward('submit'), abort: forward('abort'), entries: forward('entries'),
|
|
37
|
+
commit: async (change, context) => (await pending.remote).conversation.commit(change, context),
|
|
38
|
+
};
|
|
39
|
+
const link = {
|
|
40
|
+
reconnecting: () => pending.current?.link.reconnecting() ?? false,
|
|
41
|
+
subscribe: listener => {
|
|
42
|
+
let off, gone = false;
|
|
43
|
+
void pending.remote.then(remote => { if (!gone)
|
|
44
|
+
off = remote.link.subscribe(listener); }, () => { });
|
|
45
|
+
return () => { gone = true; off?.(); };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return { conversation, link };
|
|
49
|
+
}
|
|
50
|
+
/** What a controller that never connected still holds (messages taken from the composer, then the draft), to give to the next attempt. */
|
|
51
|
+
function unsent(controller) {
|
|
52
|
+
const snapshot = controller.getSnapshot(), drafts = [...snapshot.echo.map(item => item.draft), snapshot.draft];
|
|
53
|
+
const attachments = drafts.flatMap(item => item.attachments).filter((item, index, all) => all.findIndex(other => other.id === item.id) === index);
|
|
54
|
+
return { text: drafts.map(item => item.text).filter(text => text.trim()).join('\n'), attachments };
|
|
55
|
+
}
|
|
27
56
|
/** One owned native controller per selection; the remote transport itself reopens dropped streams. Failed acquisition retries each second. */
|
|
28
57
|
export function useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext, transport, webSocket }) {
|
|
29
58
|
const url = conversationId === undefined ? undefined : String(endpoint(conversationId));
|
|
@@ -32,30 +61,57 @@ export function useRemoteChat({ conversationId, endpoint, fetch, identity, reque
|
|
|
32
61
|
useLayoutEffect(() => { latest.current = { fetch, requestContext, webSocket }; });
|
|
33
62
|
// Transport ownership stays with the controller that acquired it, including when connect or selection changes race.
|
|
34
63
|
const remotes = useMemo(() => new WeakMap(), []);
|
|
64
|
+
// Messages a failed attempt never sent, by selection, for the next attempt's composer.
|
|
65
|
+
const carried = useMemo(() => new Map(), []);
|
|
35
66
|
const selected = useControllerPerConversation({ key,
|
|
36
|
-
|
|
67
|
+
// The controller exists at once; the remote chat opens behind it (D-1434). Its watch, submits and actions wait for the first view.
|
|
68
|
+
create: () => {
|
|
37
69
|
let controller;
|
|
38
|
-
const remote =
|
|
70
|
+
const remote = createRemoteChat({ endpoint: url, fetch: request => latest.current.fetch(request),
|
|
39
71
|
requestContext: () => {
|
|
40
72
|
const placement = readChatPlacement(controller), screen = latest.current.requestContext?.();
|
|
41
73
|
return placement || screen ? { ...screen, ...(placement && { placement }) } : undefined;
|
|
42
74
|
}, ...(transport ? { transport } : {}), ...(latest.current.webSocket ? { webSocket: latest.current.webSocket } : {}) });
|
|
75
|
+
const pending = { remote,
|
|
76
|
+
actions: { answer: async (...args) => (await remote).answer(...args), withdraw: async (...args) => (await remote).withdraw(...args) },
|
|
77
|
+
configure: async (...args) => (await remote).configure(...args) };
|
|
78
|
+
void remote.then(value => { pending.current = value; }, () => { });
|
|
43
79
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
80
|
+
const { conversation, link } = pendingConversation(pending, conversationId);
|
|
81
|
+
controller = createNativeChatController({ identity: identity, conversation, context: BACKGROUND_CONTEXT, link,
|
|
82
|
+
definitelyNotAdmitted: error => pending.current?.definitelyNotAdmitted(error) ?? false });
|
|
47
83
|
}
|
|
48
84
|
catch (error) {
|
|
49
|
-
|
|
85
|
+
void remote.then(value => value.close(), () => { });
|
|
50
86
|
throw error;
|
|
51
87
|
}
|
|
88
|
+
remotes.set(controller, pending);
|
|
89
|
+
const back = carried.get(String(key));
|
|
90
|
+
if (back) {
|
|
91
|
+
carried.delete(String(key));
|
|
92
|
+
if (back.text)
|
|
93
|
+
controller.setText(back.text);
|
|
94
|
+
if (back.attachments.length)
|
|
95
|
+
controller.setAttachments(back.attachments);
|
|
96
|
+
}
|
|
97
|
+
// `connecting` at once, so the chat shows it and takes messages; the native watch opening waits for the remote's first view.
|
|
98
|
+
void controller.connect();
|
|
99
|
+
return controller;
|
|
52
100
|
},
|
|
53
|
-
connect: controller => controller.connect(),
|
|
101
|
+
connect: async (controller) => { await remotes.get(controller).remote; await controller.connect(); },
|
|
54
102
|
dispose: async (controller) => {
|
|
103
|
+
const remote = remotes.get(controller);
|
|
104
|
+
// Disposed before its first view (a failed connect, or another selection): what it took was held, never submitted (see `send`). Its
|
|
105
|
+
// words come back in the composer of this selection's next controller, never sent by themselves.
|
|
106
|
+
if (!controller.getSnapshot().view) {
|
|
107
|
+
const back = unsent(controller);
|
|
108
|
+
if (back.text || back.attachments.length)
|
|
109
|
+
carried.set(String(key), back);
|
|
110
|
+
}
|
|
55
111
|
// Start both: closing a transport may release a pending native watch opening.
|
|
56
112
|
const pending = controller.dispose();
|
|
57
113
|
try {
|
|
58
|
-
await
|
|
114
|
+
await (remote?.current ?? await remote?.remote.catch(() => undefined))?.close();
|
|
59
115
|
}
|
|
60
116
|
finally {
|
|
61
117
|
await pending;
|
|
@@ -63,10 +119,13 @@ export function useRemoteChat({ conversationId, endpoint, fetch, identity, reque
|
|
|
63
119
|
}
|
|
64
120
|
}, });
|
|
65
121
|
if (selected.status === 'ready' && conversationId !== undefined) {
|
|
66
|
-
const remote = remotes.get(selected.controller);
|
|
122
|
+
const remote = remotes.get(selected.controller).current;
|
|
67
123
|
const actions = { ...(typeof remote.answer === 'function' ? { answer: remote.answer } : {}),
|
|
68
124
|
...(typeof remote.withdraw === 'function' ? { withdraw: remote.withdraw } : {}) };
|
|
69
125
|
return { status: 'ready', conversationId, controller: selected.controller, actions, configure: remote.configure };
|
|
70
126
|
}
|
|
71
|
-
|
|
127
|
+
if (selected.status === 'offline')
|
|
128
|
+
return { status: 'offline', conversationId, error: String(selected.error?.message ?? selected.error) };
|
|
129
|
+
const early = selected.controller && remotes.get(selected.controller);
|
|
130
|
+
return early && selected.controller ? { status: 'connecting', conversationId, controller: selected.controller, actions: early.actions, configure: early.configure } : { status: 'connecting', conversationId };
|
|
72
131
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';
|
|
2
|
-
import type { ChatAttachment, ChatController } from './native-chat.js';
|
|
2
|
+
import type { ChatAttachment, ChatController, ChatSnapshot } from './native-chat.js';
|
|
3
3
|
import type { ChatCard, DeriveOptions, Mode, QueuedMessage, Row } from './chat-view.js';
|
|
4
4
|
/** A conversation's observed file; opening remains a host operation, not workspace ownership. */
|
|
5
5
|
export interface ConversationFile {
|
|
@@ -116,9 +116,8 @@ export interface ChatSessionOptions<Content = unknown> extends Partial<DeriveOpt
|
|
|
116
116
|
/** Focus is presentation; called only by actions, never during render. */
|
|
117
117
|
readonly onFocus?: (() => void) | undefined;
|
|
118
118
|
}
|
|
119
|
-
/** Headless session over the public native controller snapshot/commands. Borrows it; cleanup only aborts this hook's uploads. */
|
|
120
119
|
export declare function useChatSession<Content = unknown>(options: ChatSessionOptions<Content>): {
|
|
121
|
-
state:
|
|
120
|
+
state: ChatSnapshot;
|
|
122
121
|
derived: import("./chat-view.js").Derived<Content>;
|
|
123
122
|
rows: Row<Content>[];
|
|
124
123
|
queued: {
|
|
@@ -3,6 +3,30 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalS
|
|
|
3
3
|
import { derive, isFileBlock, object, queuedMessages, waitingForAnswer as waitsForAnswer } from './chat-view.js';
|
|
4
4
|
import { appendToDraft, displayName, kindMentionDraft, looksLikeMarkdown, mentionKind, mentionToken, pieces, prependToDraft, withoutKindMentions, writeKindMentionDraft } from './chat-text.js';
|
|
5
5
|
/** Headless session over the public native controller snapshot/commands. Borrows it; cleanup only aborts this hook's uploads. */
|
|
6
|
+
/**
|
|
7
|
+
* The snapshot once the controller has its first view (its conversation id is then the server's); undefined if it is disposed, fails or
|
|
8
|
+
* `signal` aborts first.
|
|
9
|
+
*/
|
|
10
|
+
function connectedSnapshot(controller, signal) {
|
|
11
|
+
return new Promise(resolve => {
|
|
12
|
+
let off = () => { }, done = false;
|
|
13
|
+
const finish = (value) => { if (done)
|
|
14
|
+
return; done = true; off(); signal.removeEventListener('abort', aborted); resolve(value); };
|
|
15
|
+
const aborted = () => finish(undefined);
|
|
16
|
+
const check = () => {
|
|
17
|
+
const snapshot = controller.getSnapshot(), kind = snapshot.connection.kind;
|
|
18
|
+
if (signal.aborted)
|
|
19
|
+
finish(undefined);
|
|
20
|
+
else if (snapshot.view || snapshot.disposed || kind === 'error' || kind === 'closed')
|
|
21
|
+
finish(snapshot.view && !snapshot.disposed ? snapshot : undefined);
|
|
22
|
+
};
|
|
23
|
+
check();
|
|
24
|
+
if (done)
|
|
25
|
+
return;
|
|
26
|
+
off = controller.subscribe(check);
|
|
27
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
6
30
|
export function useChatSession(options) {
|
|
7
31
|
const { controller, activeController: active, mode, presentation, actions, renderEntry, hideLine, renderTool: catchAll, toolRenderers, groupTool, subagent, onOpenImage, onFiles, mentions, slash, attachments, model, effort, artifacts, afterSend, feedback, onOpenFile, onPickFiles, commandMentions, onFocus, labels } = options;
|
|
8
32
|
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
@@ -118,7 +142,10 @@ export function useChatSession(options) {
|
|
|
118
142
|
// Only a submission whose outcome is unknown blocks, until it is checked or retried.
|
|
119
143
|
const unconfirmed = state.send.kind === 'unknown';
|
|
120
144
|
const connected = state.connection.kind === 'connected';
|
|
121
|
-
|
|
145
|
+
// Before the first view the composer already takes messages: the controller holds them, shown as the person's own, and sends them once
|
|
146
|
+
// connected (or puts them back if the connection fails).
|
|
147
|
+
const firstConnect = state.connection.kind === 'connecting' && !state.view;
|
|
148
|
+
const sendBlocked = uploading > 0 || state.disposed || !(connected || firstConnect) || unconfirmed;
|
|
122
149
|
/** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */
|
|
123
150
|
const putBack = (text, images) => {
|
|
124
151
|
if (controller.getSnapshot().disposed)
|
|
@@ -271,8 +298,15 @@ export function useChatSession(options) {
|
|
|
271
298
|
paths = [...paths, result.path];
|
|
272
299
|
}
|
|
273
300
|
}
|
|
274
|
-
else
|
|
275
|
-
|
|
301
|
+
else {
|
|
302
|
+
// While the chat connects its conversation id is still the host's selection: the host's upload waits for the first view's.
|
|
303
|
+
const target = await connectedSnapshot(controller, abort.signal);
|
|
304
|
+
if (abort.signal.aborted || !current())
|
|
305
|
+
return;
|
|
306
|
+
if (!target)
|
|
307
|
+
throw new Error(labels.uploadFailedShort);
|
|
308
|
+
added = await onFiles(files, { identity: target.identity, conversationId: target.conversationId, signal: abort.signal });
|
|
309
|
+
}
|
|
276
310
|
if (current() && !abort.signal.aborted) {
|
|
277
311
|
if (added.length)
|
|
278
312
|
controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);
|
|
@@ -363,7 +397,7 @@ export function useChatSession(options) {
|
|
|
363
397
|
act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));
|
|
364
398
|
};
|
|
365
399
|
const empty = connected && !state.view?.entries.length && !rows.length;
|
|
366
|
-
const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;
|
|
400
|
+
const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view && !echo?.length;
|
|
367
401
|
return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current,
|
|
368
402
|
files, empty, loading, developer, natural, sending, send, putBack, upload, pasteText, picked, pending, uploading, activateMention,
|
|
369
403
|
pickMention: (path) => setPicked(value => value.includes(path) ? value : [...value, path]),
|
package/dist/file-tree-view.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { ChevronRight, Download, File, FileCode, FileImage, FileSpreadsheet, FileText, Folder, FolderInput, FolderOpen, History, LoaderCircle, MoreHorizontal, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
|
|
4
4
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
5
|
-
import { createFileTreeController } from './file-tree.js';
|
|
5
|
+
import { createFileTreeController, folderWritable } from './file-tree.js';
|
|
6
6
|
/** Every word of the tree, so a host can translate it. Functions receive what they name. */
|
|
7
7
|
export const defaultFileTreeLabels = {
|
|
8
8
|
region: 'Workspace files',
|
|
@@ -199,11 +199,11 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
199
199
|
return;
|
|
200
200
|
shownKey.current = refreshKey;
|
|
201
201
|
const { query, expanded } = controller.getSnapshot();
|
|
202
|
+
// Open folders are read again while searching too: their listing carries the host's word on writing in them.
|
|
202
203
|
if (query)
|
|
203
204
|
void controller.search(query);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
void controller.refresh(directory);
|
|
205
|
+
for (const directory of ['', ...expanded])
|
|
206
|
+
void controller.refresh(directory);
|
|
207
207
|
}, [controller, refreshKey]);
|
|
208
208
|
const tree = useRef(null);
|
|
209
209
|
const fileInput = useRef(null);
|
|
@@ -269,6 +269,13 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
};
|
|
272
|
+
// The host's per-folder word (`FilePage.writable`): a folder it marks read-only offers no upload, retry, rename, move, delete or drop.
|
|
273
|
+
const writableIn = (folder) => folderWritable(state.directories, folder);
|
|
274
|
+
const isListedFolder = (path) => [...state.directories.values(), state.search].some(listing => listing.entries.some(entry => entry.path === path && entry.kind === 'directory'));
|
|
275
|
+
/** A row changes its folder (and a folder row its own content): both must be writable. */
|
|
276
|
+
const pathWritable = (path, folder = isListedFolder(path)) => writableIn(parentPath(path)) && (!folder || writableIn(path));
|
|
277
|
+
const entryWritable = (entry) => pathWritable(entry.path, entry.kind === 'directory');
|
|
278
|
+
const canUpload = upload && writableIn(uploadDirectory);
|
|
272
279
|
const uploadRows = new Map();
|
|
273
280
|
for (const item of state.uploads) {
|
|
274
281
|
if (item.state.kind === 'pending')
|
|
@@ -279,11 +286,14 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
279
286
|
uploadRows.set(item.path, { kind: 'failed', size: item.size,
|
|
280
287
|
reason: item.state.result.reason,
|
|
281
288
|
retryLabel: item.state.result.kind === 'unknown' ? labels.checkUpload : labels.retry,
|
|
282
|
-
|
|
289
|
+
// Checking an unconfirmed upload writes nothing; trying a refused one again writes, so a read-only folder offers only the first.
|
|
290
|
+
...(item.state.result.kind === 'unknown' || writableIn(parentPath(item.path)) ? {
|
|
291
|
+
retry: () => { void controller.retry(item.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); }
|
|
292
|
+
} : {}) });
|
|
283
293
|
}
|
|
284
294
|
for (const [path, failure] of localFailures)
|
|
285
295
|
uploadRows.set(path, { kind: 'failed', size: failure.file.size, reason: failure.reason,
|
|
286
|
-
retryLabel: labels.retry, retry: () => { void uploadFiles([failure.file]); } });
|
|
296
|
+
retryLabel: labels.retry, ...(writableIn(parentPath(path)) ? { retry: () => { void uploadFiles([failure.file]); } } : {}) });
|
|
287
297
|
const uploadButton = _jsxs(Button, { type: "button", variant: "secondary", "data-file-upload": true, onClick: () => fileInput.current?.click(), children: [_jsx(Upload, { "aria-hidden": "true" }), labels.upload] });
|
|
288
298
|
const mutationRows = new Map();
|
|
289
299
|
for (const item of state.mutations)
|
|
@@ -314,7 +324,8 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
314
324
|
};
|
|
315
325
|
const commitRename = (path, value) => {
|
|
316
326
|
const name = value.trim();
|
|
317
|
-
|
|
327
|
+
// The folder may have become read-only while the name was typed: nothing is sent.
|
|
328
|
+
if (!name || name === baseName(path) || !pathWritable(path)) {
|
|
318
329
|
setRenaming(undefined);
|
|
319
330
|
focus(path);
|
|
320
331
|
return;
|
|
@@ -331,7 +342,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
331
342
|
};
|
|
332
343
|
const moveTo = (path, folder) => {
|
|
333
344
|
setAsking(undefined);
|
|
334
|
-
if (parentPath(path) === folder)
|
|
345
|
+
if (parentPath(path) === folder || !pathWritable(path) || !writableIn(folder))
|
|
335
346
|
return;
|
|
336
347
|
void controller.move(path, folder).then(report).catch(refused(path));
|
|
337
348
|
};
|
|
@@ -339,7 +350,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
339
350
|
requestAnimationFrame(() => focus(path)); };
|
|
340
351
|
const draggedPath = (event) => event.dataTransfer.types.includes(DRAGGED_FILE) ? dragged ?? event.dataTransfer.getData(DRAGGED_FILE) : undefined;
|
|
341
352
|
/** A folder (or `''`, the root) accepts a dragged file that is not already in it. */
|
|
342
|
-
const dropHandlers = (folder) => can.rename ? {
|
|
353
|
+
const dropHandlers = (folder) => can.rename && writableIn(folder) ? {
|
|
343
354
|
onDragEnter: (event) => { const path = draggedPath(event); if (path === undefined || parentPath(path) === folder)
|
|
344
355
|
return; event.preventDefault(); event.stopPropagation(); setDropTarget(folder); },
|
|
345
356
|
onDragOver: (event) => { const path = draggedPath(event); if (path === undefined || parentPath(path) === folder)
|
|
@@ -355,7 +366,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
355
366
|
event.stopPropagation();
|
|
356
367
|
setDropTarget(undefined);
|
|
357
368
|
setDragged(undefined);
|
|
358
|
-
if (path && parentPath(path) !== folder)
|
|
369
|
+
if (path && parentPath(path) !== folder && pathWritable(path))
|
|
359
370
|
void controller.move(path, folder).then(report).catch(refused(path));
|
|
360
371
|
},
|
|
361
372
|
} : {};
|
|
@@ -389,7 +400,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
389
400
|
node.focus();
|
|
390
401
|
};
|
|
391
402
|
/** Files only: a provider renames and deletes regular files, and a row busy uploading or changing waits. */
|
|
392
|
-
const mutable = (entry) => !temporaryPaths.has(entry.path) && uploadRows.get(entry.path)?.kind !== 'pending' && mutationRows.get(entry.path)?.state.kind !== 'pending';
|
|
403
|
+
const mutable = (entry) => entryWritable(entry) && !temporaryPaths.has(entry.path) && uploadRows.get(entry.path)?.kind !== 'pending' && mutationRows.get(entry.path)?.state.kind !== 'pending';
|
|
393
404
|
/** A blob pointer's row shows the file it stands for, not the pointer's own name: it can be moved or deleted, not renamed in place. */
|
|
394
405
|
const renameable = (entry) => !entry.blob;
|
|
395
406
|
const keyboard = (event, entry) => {
|
|
@@ -452,7 +463,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
452
463
|
if (listing.kind === 'error')
|
|
453
464
|
return _jsxs("li", { role: "none", children: [_jsx("span", { role: "alert", children: listing.reason }), " ", _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.search(state.query) : controller.refresh(directory)); }, children: labels.retry })] });
|
|
454
465
|
return _jsxs(_Fragment, { children: [listing.entries.length === 0 && (directory || temporaryEntries.length === 0) && _jsx("li", { role: "none", className: list && !directory ? undefined : 'file-tree-status', children: list && !directory
|
|
455
|
-
? _jsx(Empty, { className: "file-tree-empty", icon: _jsx(FolderOpen, { "aria-hidden": "true" }), title: search ? labels.noMatches : labels.empty, description: search ? undefined : labels.emptyHint, children: !search &&
|
|
466
|
+
? _jsx(Empty, { className: "file-tree-empty", icon: _jsx(FolderOpen, { "aria-hidden": "true" }), title: search ? labels.noMatches : labels.empty, description: search ? undefined : labels.emptyHint, children: !search && canUpload && uploadButton })
|
|
456
467
|
: _jsx("span", { children: search ? labels.noMatches : labels.empty }) }), listing.cursor !== undefined && _jsx("li", { role: "none", children: _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.moreSearch() : controller.more(directory)); }, children: labels.loadMore }) })] });
|
|
457
468
|
};
|
|
458
469
|
const renderEntries = (directory, level) => _jsxs(_Fragment, { children: [(state.directories.get(directory)?.entries ?? []).map(entry => renderEntry(entry, level)), listingStatus(state.directories.get(directory), directory)] });
|
|
@@ -474,7 +485,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
474
485
|
const decoration = decorate?.(entry) ?? undefined;
|
|
475
486
|
const button = _jsxs(Button, { type: "button", variant: selectedPath === entry.path ? 'secondary' : 'ghost', "data-active": selectedPath === entry.path, tabIndex: -1, title: entry.path, disabled: temporary, className: "file-tree-row-button", draggable: draggable || undefined, onClick: () => { focus(entry.path); if (!folder)
|
|
476
487
|
onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(blob?.size ?? entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent }), decoration?.detail && _jsx("span", { "data-file-detail": true, children: decoration.detail })] })] });
|
|
477
|
-
const editing = renaming?.path === entry.path && can.rename;
|
|
488
|
+
const editing = renaming?.path === entry.path && can.rename && entryWritable(entry);
|
|
478
489
|
const mutationFailed = mutation?.state.kind === 'settled';
|
|
479
490
|
const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-blob-type": blob?.type, "data-upload-state": status?.kind, "data-mutation-state": mutation ? mutationFailed ? 'failed' : 'pending' : undefined, "data-drop-target": folder && dropTarget === entry.path ? 'true' : undefined, "data-dragging": dragged === entry.path || undefined, "aria-busy": status?.kind === 'pending' || mutation?.state.kind === 'pending' || undefined, ...(folder ? dropHandlers(entry.path) : {}), ...(draggable ? {
|
|
480
491
|
onDragStart: (event) => {
|
|
@@ -507,43 +518,46 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
507
518
|
setRenaming(current => current?.path === entry.path ? undefined : current); } })] }) : folder ? _jsx(CollapsibleTrigger, { render: button }) : button, decoration?.badge && !editing && (() => {
|
|
508
519
|
const badge = { tone: decoration.badge.tone, children: decoration.badge.label, role: 'note', 'aria-label': `${name}: ${decoration.badge.label}`, title: decoration.badge.label, 'data-file-badge': '', 'data-tone': decoration.badge.tone };
|
|
509
520
|
return Badge ? _jsx(Badge, { ...badge }) : _jsx("span", { role: badge.role, "aria-label": badge['aria-label'], title: badge.title, "data-file-badge": "", "data-tone": badge.tone, children: badge.children });
|
|
510
|
-
})(), (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !temporary && !status && !editing && mutation?.state.kind !== 'pending' && (!folder || changeable && (can.rename || can.delete)) && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen && !folder ? () => onOpen(entry.path) : undefined, onHistory: history && !folder ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
|
|
521
|
+
})(), (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && status.retry && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !temporary && !status && !editing && mutation?.state.kind !== 'pending' && (!folder || changeable && (can.rename || can.delete)) && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen && !folder ? () => onOpen(entry.path) : undefined, onHistory: history && !folder ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
|
|
511
522
|
return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
|
|
512
523
|
};
|
|
513
524
|
const visibleListing = state.query ? state.search : state.directories.get('');
|
|
514
525
|
const paginated = visibleListing?.kind === 'ready' && visibleListing.cursor !== undefined;
|
|
515
526
|
const rootDrop = dropHandlers('');
|
|
516
527
|
const dialogPath = asking?.path ?? '';
|
|
517
|
-
const dialogIsFolder =
|
|
518
|
-
|
|
519
|
-
|
|
528
|
+
const dialogIsFolder = isListedFolder(dialogPath);
|
|
529
|
+
// A dialog opened before its folder became read-only closes, and its confirm checks again.
|
|
530
|
+
const askingAllowed = asking !== undefined && pathWritable(asking.path, dialogIsFolder);
|
|
531
|
+
const folders = asking?.kind === 'move' ? [...new Set(['', ...[...state.directories.values(), state.search].flatMap(listing => listing.entries.filter(entry => entry.kind === 'directory').map(entry => entry.path))])].filter(folder => folder !== dialogPath && !folder.startsWith(`${dialogPath}/`) && writableIn(folder)).sort() : [];
|
|
532
|
+
return _jsxs("section", { "data-boring": "file-tree", "data-variant": variant, "data-dropping": dropping || undefined, "data-drop-target": dropTarget === '' ? 'true' : undefined, className: className, "aria-label": labels.region, onDragEnter: event => { rootDrop.onDragEnter?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
|
|
520
533
|
event.preventDefault();
|
|
521
534
|
dragDepth.current++;
|
|
522
535
|
setDropping(true);
|
|
523
|
-
} }, onDragOver: event => { rootDrop.onDragOver?.(event); if (
|
|
536
|
+
} }, onDragOver: event => { rootDrop.onDragOver?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
|
|
524
537
|
event.preventDefault();
|
|
525
538
|
event.dataTransfer.dropEffect = 'copy';
|
|
526
|
-
} }, onDragLeave: event => { rootDrop.onDragLeave?.(event); if (
|
|
539
|
+
} }, onDragLeave: event => { rootDrop.onDragLeave?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
|
|
527
540
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
528
541
|
if (!dragDepth.current)
|
|
529
542
|
setDropping(false);
|
|
530
|
-
} }, onDrop: event => { rootDrop.onDrop?.(event); if (
|
|
543
|
+
} }, onDrop: event => { rootDrop.onDrop?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
|
|
531
544
|
event.preventDefault();
|
|
532
545
|
dragDepth.current = 0;
|
|
533
546
|
setDropping(false);
|
|
534
547
|
void uploadFiles(Array.from(event.dataTransfer.files));
|
|
535
|
-
} }, children: [list && _jsxs("header", { className: "file-tree-header", children: [_jsx("h2", { children: title ?? labels.library }), _jsxs("span", { className: "file-tree-count", "aria-label": labels.shownItems(rows.length), children: [rows.length, paginated ? '+' : ''] }),
|
|
548
|
+
} }, children: [list && _jsxs("header", { className: "file-tree-header", children: [_jsx("h2", { children: title ?? labels.library }), _jsxs("span", { className: "file-tree-count", "aria-label": labels.shownItems(rows.length), children: [rows.length, paginated ? '+' : ''] }), canUpload && uploadButton, onClose && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-file-close": true, "aria-label": labels.close, onClick: onClose, children: _jsx(X, { "aria-hidden": "true" }) })] }), _jsxs("div", { className: "file-tree-toolbar", children: [_jsxs("div", { className: "file-tree-search", children: [list && _jsx(Search, { "aria-hidden": "true" }), _jsx(Input, { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } })] }), refresh && _jsx(Button, { type: "button", variant: "ghost", "aria-label": labels.refresh, onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), !list && canUpload && uploadButton] }), canUpload && _jsx(Input, { ref: fileInput, "aria-label": labels.uploadFiles, type: "file", multiple: true, onChange: event => {
|
|
536
549
|
const files = Array.from(event.currentTarget.files ?? []);
|
|
537
550
|
event.currentTarget.value = '';
|
|
538
551
|
void uploadFiles(files);
|
|
539
552
|
} }), dropping && _jsx("div", { className: "file-tree-drop", role: "status", children: _jsx(Empty, { icon: _jsx(Upload, { "aria-hidden": "true" }), title: labels.dropFiles }) }), uploadError && _jsx("p", { role: "alert", children: uploadError }), blobUploads.size > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "file-tree-blob-uploads", children: [...blobUploads].map(([key, item]) => {
|
|
540
553
|
const percent = item.progress && item.progress.total > 0 ? Math.min(100, Math.round(item.progress.loaded / item.progress.total * 100)) : null;
|
|
541
554
|
return _jsxs("li", { "data-blob-upload": item.name, "data-progress": percent ?? undefined, className: "flex items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: item.name }), Progress ? _jsx("span", { className: "w-24 shrink-0", children: _jsx(Progress, { value: percent, "aria-label": labels.uploadProgress(item.name) }) }) : _jsx("span", { role: "status", children: percent === null ? labels.uploading : `${percent}%` }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-blob-upload-cancel": true, "aria-label": labels.cancelUpload(item.name), title: labels.cancelUpload(item.name), onClick: () => item.abort.abort(), children: _jsx(X, { "aria-hidden": "true" }) })] }, key);
|
|
542
|
-
}) }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path)), state.mutations.map(item => _jsxs("li", { "data-operation": item.operationId, children: [item.path, ": ", mutationReason(item)] }, item.operationId))] }), Dialog && _jsx(Dialog, { open:
|
|
555
|
+
}) }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path)), state.mutations.map(item => _jsxs("li", { "data-operation": item.operationId, children: [item.path, ": ", mutationReason(item)] }, item.operationId))] }), Dialog && _jsx(Dialog, { open: askingAllowed, onOpenChange: open => { if (!open)
|
|
543
556
|
closeAsking(); }, title: asking?.kind === 'move' ? labels.moveTitle(baseName(dialogPath)) : labels.deleteTitle(baseName(dialogPath)), description: asking?.kind === 'move' ? labels.moveDescription : dialogIsFolder ? labels.deleteFolderDescription : labels.deleteDescription, footer: asking?.kind === 'delete' ? _jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "outline", "data-confirm": "cancel", onClick: closeAsking, children: labels.cancel }), _jsxs(Button, { type: "button", variant: "destructive", "data-confirm": "delete", onClick: () => {
|
|
544
557
|
const path = dialogPath;
|
|
545
558
|
setAsking(undefined);
|
|
546
|
-
|
|
559
|
+
if (pathWritable(path, dialogIsFolder))
|
|
560
|
+
void controller.remove(path).then(report).catch(refused(path));
|
|
547
561
|
}, children: [_jsx(Trash2, { "aria-hidden": "true" }), labels.delete] })] }) : _jsx(Button, { type: "button", variant: "outline", "data-confirm": "cancel", onClick: closeAsking, children: labels.cancel }), children: asking?.kind === 'move' && _jsx("ul", { className: "file-tree-destinations", "aria-label": labels.moveTitle(baseName(dialogPath)), children: folders.map(folder => _jsx("li", { children: _jsxs(Button, { type: "button", variant: "ghost", "data-destination": folder, disabled: folder === parentPath(dialogPath), onClick: () => moveTo(dialogPath, folder), children: [folder ? _jsx(Folder, { "aria-hidden": "true" }) : _jsx(FolderOpen, { "aria-hidden": "true" }), _jsx("span", { className: "file-tree-name", children: folder || labels.root })] }) }, folder)) }) }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
|
|
548
562
|
}
|
|
549
563
|
/** The message of a host refusal `{ error: { code, message } }` (or an Error's), if any. */
|
package/dist/file-tree.d.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import type { PublicationResult, ResourceRef } from '@hachej/boring-files';
|
|
2
2
|
import type { FileEntry, RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';
|
|
3
|
+
/**
|
|
4
|
+
* `writable`: the host's word on writing in this folder (`FilePage.writable`); `false` also when the host refused the listing (403). A
|
|
5
|
+
* reload or a failed read keeps the last word, so a read-only folder never regains its controls while it is read again.
|
|
6
|
+
*/
|
|
3
7
|
export type FileListing = {
|
|
4
8
|
readonly kind: 'loading';
|
|
5
9
|
readonly entries: readonly FileEntry[];
|
|
10
|
+
readonly writable?: boolean;
|
|
6
11
|
} | {
|
|
7
12
|
readonly kind: 'ready';
|
|
8
13
|
readonly entries: readonly FileEntry[];
|
|
9
14
|
readonly cursor?: string;
|
|
15
|
+
readonly writable?: boolean;
|
|
10
16
|
} | {
|
|
11
17
|
readonly kind: 'error';
|
|
12
18
|
readonly entries: readonly FileEntry[];
|
|
13
19
|
readonly reason: string;
|
|
20
|
+
readonly writable?: boolean;
|
|
14
21
|
};
|
|
15
22
|
export interface FileUpload {
|
|
16
23
|
readonly operationId: string;
|
|
@@ -84,6 +91,11 @@ export interface FileTreeController {
|
|
|
84
91
|
readonly dismiss: (operationId: string) => void;
|
|
85
92
|
readonly dispose: () => void;
|
|
86
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether the host lets the caller write in `folder` (`''` is the root): the flag of its own listing, or of the nearest listed folder
|
|
96
|
+
* above it while it is not listed. True when no listing says otherwise, so a host that sends no flag keeps every control.
|
|
97
|
+
*/
|
|
98
|
+
export declare function folderWritable(directories: ReadonlyMap<string, FileListing>, folder: string): boolean;
|
|
87
99
|
/** Browsing state belongs to one borrowed binding. Disposal fences late results; it never closes the workspace. */
|
|
88
100
|
export declare function createFileTreeController({ revisionProvider }: {
|
|
89
101
|
readonly revisionProvider: RevisionProvider;
|
package/dist/file-tree.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import { parsePublicationResult, PublicationNotDispatchedError, publicationDigest, publicationSnapshot } from '@hachej/boring-files/publication';
|
|
2
2
|
import { randomUUID } from '@hachej/boring-files/platform';
|
|
3
3
|
const message = (error) => error instanceof Error ? error.message : 'Files could not be loaded';
|
|
4
|
+
/**
|
|
5
|
+
* What a folder listing's failure means for the tree, read by shape (a hand-built binding may throw a `RevisionError` from another copy
|
|
6
|
+
* of the package): the host's 403 makes the folder read-only, its 404 makes it gone. A `denied` without that 403 (a 409 binding
|
|
7
|
+
* mismatch, an answer for another workspace) stays an error.
|
|
8
|
+
*/
|
|
9
|
+
function refusal(error) {
|
|
10
|
+
if (!(error instanceof Error) || error.name !== 'RevisionError' || !('kind' in error))
|
|
11
|
+
return undefined;
|
|
12
|
+
if (error.kind === 'missing')
|
|
13
|
+
return 'gone';
|
|
14
|
+
return error.kind === 'denied' && 'status' in error && error.status === 403 ? 'read-only' : undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether the host lets the caller write in `folder` (`''` is the root): the flag of its own listing, or of the nearest listed folder
|
|
18
|
+
* above it while it is not listed. True when no listing says otherwise, so a host that sends no flag keeps every control.
|
|
19
|
+
*/
|
|
20
|
+
export function folderWritable(directories, folder) {
|
|
21
|
+
for (let path = folder;; path = path.slice(0, Math.max(0, path.lastIndexOf('/')))) {
|
|
22
|
+
const listing = directories.get(path);
|
|
23
|
+
if (listing?.writable !== undefined)
|
|
24
|
+
return listing.writable;
|
|
25
|
+
if (!path)
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
4
29
|
const empty = { kind: 'ready', entries: [] };
|
|
5
30
|
const parentOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf('/')));
|
|
6
31
|
const nameOf = (path) => path.slice(path.lastIndexOf('/') + 1);
|
|
@@ -49,18 +74,30 @@ export function createFileTreeController({ revisionProvider }) {
|
|
|
49
74
|
const signal = AbortSignal.any([abort.signal, lifetime.signal]);
|
|
50
75
|
const query = state.query;
|
|
51
76
|
const set = (value) => search ? update({ ...state, search: value }) : directory(path, value);
|
|
52
|
-
|
|
77
|
+
const known = search || previous.writable === undefined ? {} : { writable: previous.writable };
|
|
78
|
+
set({ kind: 'loading', entries: previous.entries, ...known });
|
|
53
79
|
try {
|
|
54
80
|
const page = await (search ? revisionProvider.search({ query, ...(cursor ? { cursor } : {}) }, signal)
|
|
55
81
|
: revisionProvider.list({ directory: path, ...(cursor ? { cursor } : {}) }, signal));
|
|
56
82
|
if (signal.aborted || loads.get(key) !== abort)
|
|
57
83
|
return;
|
|
58
84
|
const entries = append ? [...new Map([...previous.entries, ...page.entries].map(entry => [entry.path, entry])).values()] : [...page.entries];
|
|
59
|
-
|
|
85
|
+
// A next page without the flag keeps the first page's word; a fresh read takes the host's word as it is now.
|
|
86
|
+
const writable = search ? {} : typeof page.writable === 'boolean' ? { writable: page.writable } : append ? known : {};
|
|
87
|
+
set({ kind: 'ready', entries, ...(page.cursor === undefined ? {} : { cursor: page.cursor }), ...writable });
|
|
60
88
|
}
|
|
61
89
|
catch (error) {
|
|
62
|
-
if (
|
|
63
|
-
|
|
90
|
+
if (signal.aborted || loads.get(key) !== abort)
|
|
91
|
+
return;
|
|
92
|
+
const kind = search || !path ? undefined : refusal(error);
|
|
93
|
+
// A folder the host refuses to list (403) is shown read-only and empty, with no error; one it says is not there (404) leaves the
|
|
94
|
+
// tree: its row, its listing and its open state. The root keeps its error, the workspace itself being out of reach.
|
|
95
|
+
if (kind === 'read-only')
|
|
96
|
+
set({ kind: 'ready', entries: [], writable: false });
|
|
97
|
+
else if (kind === 'gone')
|
|
98
|
+
gone(path);
|
|
99
|
+
else
|
|
100
|
+
set({ kind: 'error', entries: previous.entries, reason: message(error), ...known });
|
|
64
101
|
}
|
|
65
102
|
finally {
|
|
66
103
|
if (loads.get(key) === abort)
|
|
@@ -68,6 +105,21 @@ export function createFileTreeController({ revisionProvider }) {
|
|
|
68
105
|
}
|
|
69
106
|
}
|
|
70
107
|
const refresh = (path = '') => load(path, false);
|
|
108
|
+
/** A folder the host says is not there: removed with everything under it (listings, pending reads, search rows) and from its parent. */
|
|
109
|
+
function gone(path) {
|
|
110
|
+
const under = (item) => item === path || item.startsWith(`${path}/`);
|
|
111
|
+
for (const [key, pending] of loads)
|
|
112
|
+
if (key.startsWith('directory:') && under(key.slice('directory:'.length))) {
|
|
113
|
+
pending.abort();
|
|
114
|
+
loads.delete(key);
|
|
115
|
+
}
|
|
116
|
+
const directories = new Map([...state.directories].filter(([folder]) => !under(folder)));
|
|
117
|
+
const parent = directories.get(parentOf(path));
|
|
118
|
+
if (parent)
|
|
119
|
+
directories.set(parentOf(path), { ...parent, entries: parent.entries.filter(entry => entry.path !== path) });
|
|
120
|
+
const search = state.search.entries.some(entry => under(entry.path)) ? { ...state.search, entries: state.search.entries.filter(entry => !under(entry.path)) } : state.search;
|
|
121
|
+
update({ ...state, directories, search, expanded: new Set([...state.expanded].filter(folder => !under(folder))) });
|
|
122
|
+
}
|
|
71
123
|
function uploadState(operationId, result) {
|
|
72
124
|
const attempt = attempts.get(operationId);
|
|
73
125
|
if (!attempt)
|