@hienlh/ppm 0.18.4 → 0.18.6
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 +19 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-Bb-NNlp1.js → ai-resource-editor-C59ti0ED.js} +1 -1
- package/dist/web/assets/{audio-preview-DG2Tw0cO.js → audio-preview-GGksv6U8.js} +1 -1
- package/dist/web/assets/chat-tab-CM5hUNpk.js +15 -0
- package/dist/web/assets/code-editor-2yL9lCL1.js +10 -0
- package/dist/web/assets/{conflict-editor-B6hVffX3.js → conflict-editor-H1zmUXSQ.js} +1 -1
- package/dist/web/assets/{csv-preview-hL4Z9HS5.js → csv-preview-Dzzyx_Gv.js} +1 -1
- package/dist/web/assets/{database-viewer-CTK5kMaA.js → database-viewer-CiVA--mM.js} +1 -1
- package/dist/web/assets/{diff-viewer-DZxnANY4.js → diff-viewer-69nl3mqO.js} +1 -1
- package/dist/web/assets/{docx-preview-CrAc7Qjv.js → docx-preview-CFvopEBf.js} +1 -1
- package/dist/web/assets/explorer-body-DyPXovdU.js +2 -0
- package/dist/web/assets/{explorer-window-content-NS8Y0v8c.js → explorer-window-content-CXFdvs_i.js} +1 -1
- package/dist/web/assets/{extension-webview-D6eywKPN.js → extension-webview-DIoKwGV4.js} +1 -1
- package/dist/web/assets/{git-log-panel-DAogZ9QV.js → git-log-panel-Copm8mzy.js} +1 -1
- package/dist/web/assets/{glide-data-grid-DxfJRN-r.js → glide-data-grid-D0L8SZVt.js} +4 -4
- package/dist/web/assets/group-chat-tab-D9cazEAI.js +1 -0
- package/dist/web/assets/{image-preview-Byjx8MoL.js → image-preview-vFeN8o43.js} +1 -1
- package/dist/web/assets/{index-CcN0TBm4.js → index-BewcuJw5.js} +24 -24
- package/dist/web/assets/index-oxsyfDa0.css +2 -0
- package/dist/web/assets/keybindings-store-ruNjhVT3.js +1 -0
- package/dist/web/assets/{markdown-renderer-CXlEe3ma.js → markdown-renderer-B46VJEHH.js} +2 -2
- package/dist/web/assets/markdown-renderer-CYiMPviS.js +1 -0
- package/dist/web/assets/{mobile-explorer-sheet-BuqBlP3o.js → mobile-explorer-sheet-BkMZxqs3.js} +1 -1
- package/dist/web/assets/notification-store-B3IK7PXf.js +1 -0
- package/dist/web/assets/{pdf-preview-F2DWVB85.js → pdf-preview-DU_NRMno.js} +1 -1
- package/dist/web/assets/{postgres-viewer-KZqPYfOB.js → postgres-viewer-C0h3Zfk9.js} +2 -2
- package/dist/web/assets/{settings-store-CY-4_U3H.js → settings-store-C9YT1Hir.js} +2 -2
- package/dist/web/assets/{settings-tab-Khj-w6Sr.js → settings-tab-Cep8gjxG.js} +1 -1
- package/dist/web/assets/{sql-query-editor-Cp0_e2P1.js → sql-query-editor-DJofTC9F.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CIv3sagZ.js → sqlite-viewer-D7bskeyk.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-CrssTOf4.js → system-monitor-tab-CxgFsQx9.js} +1 -1
- package/dist/web/assets/team-member-sheet-Dn5NGkcp.js +2 -0
- package/dist/web/assets/{team-member-window-content-D-hXax3c.js → team-member-window-content-DS9V4trr.js} +1 -1
- package/dist/web/assets/{terminal-tab-BIQDG-Ti.js → terminal-tab-CMs8AyFJ.js} +3 -3
- package/dist/web/assets/tool-cards-HHRTIlFv.js +8 -0
- package/dist/web/assets/{use-monaco-theme-Djp1_fKi.js → use-monaco-theme-BLt-n9r1.js} +1 -1
- package/dist/web/assets/use-open-team-member-BwtzTuxf.js +1 -0
- package/dist/web/assets/{use-websocket-CCYA9-a2.js → use-websocket-DA3Zwl3Z.js} +1 -1
- package/dist/web/assets/video-preview-BZhTYMFX.js +1 -0
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/web/app.tsx +12 -0
- package/src/web/components/chat/chat-tab.tsx +11 -0
- package/src/web/components/chat/team-activity-panel.tsx +4 -4
- package/src/web/components/chat/team-member-activity-format.ts +38 -0
- package/src/web/components/chat/team-member-activity-row.tsx +1 -29
- package/src/web/components/chat/team-member-sheet.tsx +47 -0
- package/src/web/components/chat/team-working-bar.tsx +62 -0
- package/src/web/components/chat/use-open-team-member.ts +41 -0
- package/src/web/components/os-explorer/actions/collision-prompt-queue.ts +133 -0
- package/src/web/components/os-explorer/actions/explorer-actions-clipboard.ts +29 -3
- package/src/web/components/os-explorer/actions/explorer-actions-upload.ts +88 -33
- package/src/web/components/os-explorer/actions/use-collision-prompt.ts +56 -0
- package/src/web/components/os-explorer/actions/use-explorer-actions.ts +29 -44
- package/src/web/components/os-explorer/actions/use-permanent-overwrite-prompt.ts +47 -0
- package/src/web/components/os-explorer/dnd/use-drop-transfer.tsx +19 -27
- package/src/web/components/os-explorer/explorer-dialogs.tsx +12 -1
- package/src/web/components/os-explorer/upload/capture-picked-files.ts +18 -0
- package/src/web/components/os-explorer/upload/upload-batch-card.tsx +64 -0
- package/src/web/components/os-explorer/upload/upload-batch-progress.ts +34 -0
- package/src/web/components/os-explorer/upload/upload-batch-row.tsx +79 -0
- package/src/web/components/os-explorer/upload/upload-error-message.ts +19 -0
- package/src/web/components/os-explorer/upload/upload-progress-panel.tsx +72 -0
- package/src/web/components/os-explorer/upload/upload-store.ts +109 -0
- package/src/web/components/os-explorer/upload/use-upload-picker.tsx +7 -5
- package/src/web/lib/fs-upload-xhr.ts +13 -2
- package/dist/web/assets/chat-tab-CgAv-crh.js +0 -15
- package/dist/web/assets/code-editor-bX4jlgMy.js +0 -10
- package/dist/web/assets/explorer-body-DykJXCTw.js +0 -2
- package/dist/web/assets/group-chat-tab-DkVi6qL2.js +0 -1
- package/dist/web/assets/index-Bucbpby-.css +0 -2
- package/dist/web/assets/keybindings-store-DJIHqbLM.js +0 -1
- package/dist/web/assets/markdown-renderer-pYznJDbh.js +0 -1
- package/dist/web/assets/notification-store-Bcl5efY2.js +0 -1
- package/dist/web/assets/tool-cards-Dx-Rrpri.js +0 -8
- package/dist/web/assets/video-preview-cvjNMjWA.js +0 -1
- /package/dist/web/assets/{api-themes-CEyYK7AK.js → api-themes-8WTH6KpV.js} +0 -0
- /package/dist/web/assets/{can-open-in-ppm-Cu-XYIgu.js → can-open-in-ppm-DXGOSUQc.js} +0 -0
- /package/dist/web/assets/{csv-parser-r-Nk1oHT.js → csv-parser-BRtnJCfv.js} +0 -0
- /package/dist/web/assets/{edit-diff-preview-HTZPC9VP.js → edit-diff-preview-BivQwZMD.js} +0 -0
- /package/dist/web/assets/{lib-BzxL1ARZ.js → lib-B1clP4ai.js} +0 -0
- /package/dist/web/assets/{media-url-C5vA0ha9.js → media-url-CUpN5vI1.js} +0 -0
- /package/dist/web/assets/{run-in-terminal-B8TDtaM_.js → run-in-terminal-CGW6o37_.js} +0 -0
- /package/dist/web/assets/{send-to-chat-CuBAVC8l.js → send-to-chat-YXHdAyLG.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-BeaIjRrv.js → vendor-xterm-C1diXHJl.js} +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React wrapper around `CollisionPromptQueue`. Owns one queue instance per window/surface
|
|
3
|
+
* (`use-explorer-actions.ts` and `use-drop-transfer.tsx` each hold their own), exposing the
|
|
4
|
+
* dialog-facing state plus the `resolve`/`startBatch`/`endBatch` trio that `TransferContext`
|
|
5
|
+
* plugs into `transfer()` and `uploadEntries()`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useMemo, useRef, useState } from "react";
|
|
9
|
+
import { CollisionPromptQueue, type CollisionChoice, type CollisionRequest } from "./collision-prompt-queue";
|
|
10
|
+
|
|
11
|
+
export interface CollisionPromptState extends CollisionRequest {
|
|
12
|
+
/** How many more collisions are queued behind this one. */
|
|
13
|
+
remaining: number;
|
|
14
|
+
applyToAll: boolean;
|
|
15
|
+
setApplyToAll(value: boolean): void;
|
|
16
|
+
resolve(choice: CollisionChoice): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CollisionPrompt {
|
|
20
|
+
/** Render-ready state for the dialog, or `null` when nothing is queued. */
|
|
21
|
+
state: CollisionPromptState | null;
|
|
22
|
+
resolve(request: CollisionRequest): Promise<CollisionChoice>;
|
|
23
|
+
startBatch(): void;
|
|
24
|
+
endBatch(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useCollisionPrompt(): CollisionPrompt {
|
|
28
|
+
const [tick, setTick] = useState(0);
|
|
29
|
+
const [applyToAll, setApplyToAll] = useState(false);
|
|
30
|
+
const queueRef = useRef<CollisionPromptQueue | null>(null);
|
|
31
|
+
if (!queueRef.current) {
|
|
32
|
+
queueRef.current = new CollisionPromptQueue(() => setTick((n) => n + 1));
|
|
33
|
+
}
|
|
34
|
+
const queue = queueRef.current;
|
|
35
|
+
|
|
36
|
+
return useMemo(() => {
|
|
37
|
+
const snapshot = queue.snapshot();
|
|
38
|
+
return {
|
|
39
|
+
state: snapshot && {
|
|
40
|
+
...snapshot.request,
|
|
41
|
+
remaining: snapshot.remaining,
|
|
42
|
+
applyToAll,
|
|
43
|
+
setApplyToAll,
|
|
44
|
+
resolve: (choice) => {
|
|
45
|
+
queue.choose(choice, applyToAll);
|
|
46
|
+
setApplyToAll(false);
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
resolve: (request) => queue.request(request),
|
|
50
|
+
startBatch: () => queue.startBatch(),
|
|
51
|
+
endBatch: () => queue.endBatch(),
|
|
52
|
+
};
|
|
53
|
+
// `tick` has no direct use below — it exists purely to force this memo to recompute
|
|
54
|
+
// whenever the queue notifies of a change (new request queued, or one resolved).
|
|
55
|
+
}, [queue, applyToAll, tick]);
|
|
56
|
+
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Binds the action modules to one window's slice and owns the state of
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Prompts are modelled as promises resolved by the dialog, so the action modules stay
|
|
7
|
-
* plain async functions with no React inside them.
|
|
2
|
+
* Binds the action modules to one window's slice and owns the state of its interruptions:
|
|
3
|
+
* the collision prompt (`use-collision-prompt.ts`), the permanent-delete confirmation and the
|
|
4
|
+
* properties dialog. Prompts are modelled as promises resolved by the dialog, so the action
|
|
5
|
+
* modules stay plain async functions with no React inside them.
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
8
|
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
|
@@ -15,12 +13,11 @@ import { useExplorerPinsStore } from "../explorer-pins-store";
|
|
|
15
13
|
import { useExplorerStore, type ExplorerSlice } from "../explorer-store";
|
|
16
14
|
import type { ExplorerNavigation } from "../use-explorer-navigation";
|
|
17
15
|
import { copyTextLines, downloadEntries, openEntryInPpm, openPathInNewWindow } from "./explorer-actions-open";
|
|
18
|
-
import {
|
|
19
|
-
pasteInto, setClipboardPaths, transfer,
|
|
20
|
-
type CollisionChoice, type CollisionRequest,
|
|
21
|
-
} from "./explorer-actions-clipboard";
|
|
16
|
+
import { pasteInto, setClipboardPaths, transfer } from "./explorer-actions-clipboard";
|
|
22
17
|
import { createEntry, deleteEntries, renameEntry, validateEntryName } from "./explorer-actions-mutate";
|
|
23
18
|
import { useExplorerUploadActions } from "./use-explorer-upload-actions";
|
|
19
|
+
import { useCollisionPrompt, type CollisionPromptState } from "./use-collision-prompt";
|
|
20
|
+
import { usePermanentOverwritePrompt, type PermanentOverwritePromptState } from "./use-permanent-overwrite-prompt";
|
|
24
21
|
import type { DroppedEntry } from "../upload/collect-dropped-entries";
|
|
25
22
|
|
|
26
23
|
export interface ExplorerActions {
|
|
@@ -52,10 +49,10 @@ export interface ExplorerActions {
|
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
export interface ExplorerDialogState {
|
|
55
|
-
collision:
|
|
52
|
+
collision: CollisionPromptState | null;
|
|
56
53
|
pendingDelete: { paths: string[]; names: string[] } | null;
|
|
57
54
|
/** "Replace" hit a host with no trash backend — confirm a permanent overwrite instead. */
|
|
58
|
-
permanentOverwrite:
|
|
55
|
+
permanentOverwrite: PermanentOverwritePromptState | null;
|
|
59
56
|
properties: FsEntry | null;
|
|
60
57
|
inlineError: string | null;
|
|
61
58
|
closeDelete(): void;
|
|
@@ -72,43 +69,31 @@ export function useExplorerActions(
|
|
|
72
69
|
platform: string | undefined,
|
|
73
70
|
): { actions: ExplorerActions; dialogs: ExplorerDialogState } {
|
|
74
71
|
const patch = useExplorerStore((s) => s.patch);
|
|
75
|
-
const
|
|
72
|
+
const collisionPrompt = useCollisionPrompt();
|
|
73
|
+
const permanentOverwritePrompt = usePermanentOverwritePrompt();
|
|
76
74
|
const [pendingDelete, setPendingDelete] = useState<ExplorerDialogState["pendingDelete"]>(null);
|
|
77
|
-
const [permanentOverwrite, setPermanentOverwrite] = useState<ExplorerDialogState["permanentOverwrite"]>(null);
|
|
78
75
|
const [properties, setProperties] = useState<FsEntry | null>(null);
|
|
79
76
|
const [inlineError, setInlineError] = useState<string | null>(null);
|
|
80
77
|
|
|
81
78
|
const sliceRef = useRef(slice);
|
|
82
79
|
sliceRef.current = slice;
|
|
83
80
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
new Promise<boolean>((resolve) => {
|
|
101
|
-
setPermanentOverwrite({
|
|
102
|
-
name,
|
|
103
|
-
resolve: (proceed) => {
|
|
104
|
-
setPermanentOverwrite(null);
|
|
105
|
-
resolve(proceed);
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
}),
|
|
109
|
-
}),
|
|
110
|
-
[],
|
|
111
|
-
);
|
|
81
|
+
// Read through refs so `context` keeps a stable identity ([]-deps) even though both
|
|
82
|
+
// prompts' own functions change identity on every queued/resolved request.
|
|
83
|
+
const collisionPromptRef = useRef(collisionPrompt);
|
|
84
|
+
collisionPromptRef.current = collisionPrompt;
|
|
85
|
+
const permanentOverwritePromptRef = useRef(permanentOverwritePrompt);
|
|
86
|
+
permanentOverwritePromptRef.current = permanentOverwritePrompt;
|
|
87
|
+
|
|
88
|
+
const context = useMemo(() => ({
|
|
89
|
+
get sep() {
|
|
90
|
+
return sliceRef.current?.sep ?? "/";
|
|
91
|
+
},
|
|
92
|
+
resolve: (request: Parameters<typeof collisionPrompt.resolve>[0]) => collisionPromptRef.current.resolve(request),
|
|
93
|
+
startBatch: () => collisionPromptRef.current.startBatch(),
|
|
94
|
+
endBatch: () => collisionPromptRef.current.endBatch(),
|
|
95
|
+
confirmPermanentOverwrite: (name: string) => permanentOverwritePromptRef.current.confirm(name),
|
|
96
|
+
}), []);
|
|
112
97
|
|
|
113
98
|
const currentDir = () => sliceRef.current?.path ?? "";
|
|
114
99
|
const upload = useExplorerUploadActions(context, currentDir);
|
|
@@ -212,9 +197,9 @@ export function useExplorerActions(
|
|
|
212
197
|
return {
|
|
213
198
|
actions,
|
|
214
199
|
dialogs: {
|
|
215
|
-
collision,
|
|
200
|
+
collision: collisionPrompt.state,
|
|
216
201
|
pendingDelete,
|
|
217
|
-
permanentOverwrite,
|
|
202
|
+
permanentOverwrite: permanentOverwritePrompt.state,
|
|
218
203
|
properties,
|
|
219
204
|
inlineError,
|
|
220
205
|
closeDelete: () => setPendingDelete(null),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes the "no Trash on this host" confirm (`ctx.confirmPermanentOverwrite`) behind the
|
|
3
|
+
* same one-at-a-time queue as the collision prompt (`SerialPromptQueue`, see
|
|
4
|
+
* `collision-prompt-queue.ts`). This confirm used to be its own bare `useState` slot with the
|
|
5
|
+
* exact same race: a Replace that hits `NO_TRASH` on two concurrent jobs could have the second
|
|
6
|
+
* call silently overwrite the first's dialog, leaving the first job's promise unresolved
|
|
7
|
+
* forever. No "apply to all" here — it is a much rarer prompt than a name collision, so a
|
|
8
|
+
* plain FIFO queue is all it needs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useMemo, useRef, useState } from "react";
|
|
12
|
+
import { SerialPromptQueue } from "./collision-prompt-queue";
|
|
13
|
+
|
|
14
|
+
export interface PermanentOverwritePromptState {
|
|
15
|
+
name: string;
|
|
16
|
+
/** How many more confirms are queued behind this one. */
|
|
17
|
+
remaining: number;
|
|
18
|
+
resolve(proceed: boolean): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PermanentOverwritePrompt {
|
|
22
|
+
state: PermanentOverwritePromptState | null;
|
|
23
|
+
confirm(name: string): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function usePermanentOverwritePrompt(): PermanentOverwritePrompt {
|
|
27
|
+
const [tick, setTick] = useState(0);
|
|
28
|
+
const queueRef = useRef<SerialPromptQueue<string, boolean> | null>(null);
|
|
29
|
+
if (!queueRef.current) {
|
|
30
|
+
queueRef.current = new SerialPromptQueue(() => setTick((n) => n + 1));
|
|
31
|
+
}
|
|
32
|
+
const queue = queueRef.current;
|
|
33
|
+
|
|
34
|
+
return useMemo(() => {
|
|
35
|
+
const snapshot = queue.snapshot();
|
|
36
|
+
return {
|
|
37
|
+
state: snapshot && {
|
|
38
|
+
name: snapshot.request,
|
|
39
|
+
remaining: snapshot.remaining,
|
|
40
|
+
resolve: (proceed) => queue.choose(proceed),
|
|
41
|
+
},
|
|
42
|
+
confirm: (name) => queue.request(name),
|
|
43
|
+
};
|
|
44
|
+
// `tick` has no direct use below — it forces this memo to recompute whenever the queue
|
|
45
|
+
// notifies of a change (new confirm queued, or one resolved).
|
|
46
|
+
}, [queue, tick]);
|
|
47
|
+
}
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* only the promise wiring is local — so a collision looks identical wherever the drop landed.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useEffect, useMemo, useRef,
|
|
12
|
-
import type {
|
|
11
|
+
import { useEffect, useMemo, useRef, type ReactNode } from "react";
|
|
12
|
+
import type { TransferContext } from "../actions/explorer-actions-clipboard";
|
|
13
13
|
import { uploadEntries } from "../actions/explorer-actions-upload";
|
|
14
|
-
import
|
|
14
|
+
import { useCollisionPrompt } from "../actions/use-collision-prompt";
|
|
15
|
+
import { usePermanentOverwritePrompt } from "../actions/use-permanent-overwrite-prompt";
|
|
15
16
|
import { ExplorerDialogs } from "../explorer-dialogs";
|
|
16
17
|
import type { DroppedEntry } from "../upload/collect-dropped-entries";
|
|
17
18
|
import { transferRunner, type DropRunner } from "./entry-drop-executor";
|
|
@@ -25,8 +26,8 @@ export interface DropTransfer {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function useDropTransfer(sep: string, platform?: string): DropTransfer {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
29
|
+
const collisionPrompt = useCollisionPrompt();
|
|
30
|
+
const permanentOverwritePrompt = usePermanentOverwritePrompt();
|
|
30
31
|
|
|
31
32
|
// The separator can change under a long-lived surface (the tree follows the active
|
|
32
33
|
// project), so the context reads it live rather than closing over the first value.
|
|
@@ -34,31 +35,22 @@ export function useDropTransfer(sep: string, platform?: string): DropTransfer {
|
|
|
34
35
|
const sepRef = useRef(sep);
|
|
35
36
|
useEffect(() => { sepRef.current = sep; }, [sep]);
|
|
36
37
|
|
|
38
|
+
// Read through refs so `context`'s identity stays stable across renders — see the same
|
|
39
|
+
// pattern in `use-explorer-actions.ts`.
|
|
40
|
+
const collisionPromptRef = useRef(collisionPrompt);
|
|
41
|
+
collisionPromptRef.current = collisionPrompt;
|
|
42
|
+
const permanentOverwritePromptRef = useRef(permanentOverwritePrompt);
|
|
43
|
+
permanentOverwritePromptRef.current = permanentOverwritePrompt;
|
|
44
|
+
|
|
37
45
|
const context = useMemo<TransferContext>(
|
|
38
46
|
() => ({
|
|
39
47
|
get sep() {
|
|
40
48
|
return sepRef.current;
|
|
41
49
|
},
|
|
42
|
-
resolve: (request
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
resolve: (choice) => {
|
|
47
|
-
setCollision(null);
|
|
48
|
-
resolve(choice);
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
}),
|
|
52
|
-
confirmPermanentOverwrite: (name: string) =>
|
|
53
|
-
new Promise<boolean>((resolve) => {
|
|
54
|
-
setPermanentOverwrite({
|
|
55
|
-
name,
|
|
56
|
-
resolve: (proceed) => {
|
|
57
|
-
setPermanentOverwrite(null);
|
|
58
|
-
resolve(proceed);
|
|
59
|
-
},
|
|
60
|
-
});
|
|
61
|
-
}),
|
|
50
|
+
resolve: (request) => collisionPromptRef.current.resolve(request),
|
|
51
|
+
startBatch: () => collisionPromptRef.current.startBatch(),
|
|
52
|
+
endBatch: () => collisionPromptRef.current.endBatch(),
|
|
53
|
+
confirmPermanentOverwrite: (name: string) => permanentOverwritePromptRef.current.confirm(name),
|
|
62
54
|
}),
|
|
63
55
|
[],
|
|
64
56
|
);
|
|
@@ -72,8 +64,8 @@ export function useDropTransfer(sep: string, platform?: string): DropTransfer {
|
|
|
72
64
|
const prompts = (
|
|
73
65
|
<ExplorerDialogs
|
|
74
66
|
dialogs={{
|
|
75
|
-
collision,
|
|
76
|
-
permanentOverwrite,
|
|
67
|
+
collision: collisionPrompt.state,
|
|
68
|
+
permanentOverwrite: permanentOverwritePrompt.state,
|
|
77
69
|
pendingDelete: null,
|
|
78
70
|
properties: null,
|
|
79
71
|
inlineError: null,
|
|
@@ -31,7 +31,7 @@ export function ExplorerDialogs({ dialogs, platform, sep }: ExplorerDialogsProps
|
|
|
31
31
|
// Dismissing without choosing must not silently overwrite anything.
|
|
32
32
|
onClose={() => collision.resolve("skip")}
|
|
33
33
|
title="An item with that name already exists"
|
|
34
|
-
description={collision.name}
|
|
34
|
+
description={collision.remaining > 0 ? `${collision.name} — and ${collision.remaining} more` : collision.name}
|
|
35
35
|
footer={
|
|
36
36
|
<>
|
|
37
37
|
<Button variant="outline" onClick={() => collision.resolve("skip")}>Skip</Button>
|
|
@@ -44,6 +44,17 @@ export function ExplorerDialogs({ dialogs, platform, sep }: ExplorerDialogsProps
|
|
|
44
44
|
Replacing moves the existing item to the Trash first, so it stays recoverable.
|
|
45
45
|
Keep both adds a numbered suffix.
|
|
46
46
|
</p>
|
|
47
|
+
{collision.remaining > 0 && (
|
|
48
|
+
<label className="flex min-h-11 items-center gap-2 text-sm text-text-2">
|
|
49
|
+
<input
|
|
50
|
+
type="checkbox"
|
|
51
|
+
className="size-4"
|
|
52
|
+
checked={collision.applyToAll}
|
|
53
|
+
onChange={(e) => collision.setApplyToAll(e.target.checked)}
|
|
54
|
+
/>
|
|
55
|
+
Apply to all {collision.remaining} remaining item{collision.remaining === 1 ? "" : "s"}
|
|
56
|
+
</label>
|
|
57
|
+
)}
|
|
47
58
|
</ExplorerModalShell>
|
|
48
59
|
)}
|
|
49
60
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulls every file out of a (possibly live) `FileList`-shaped object into a plain array,
|
|
3
|
+
* BEFORE the caller resets the `<input>`'s value. Some Chromium versions empty the input's
|
|
4
|
+
* *live* `FileList` the instant `value` is reset, so calling `resetInput` first silently
|
|
5
|
+
* drops every picked file — this ordering is the one thing that must never be inlined and
|
|
6
|
+
* reshuffled, hence its own pure, unit-testable function.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface PickableFileList {
|
|
10
|
+
length: number;
|
|
11
|
+
item(index: number): File | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function capturePickedFiles(list: PickableFileList | null, resetInput: () => void): File[] {
|
|
15
|
+
const files = list ? Array.from({ length: list.length }, (_, i) => list.item(i)).filter((f): f is File => f != null) : [];
|
|
16
|
+
resetInput();
|
|
17
|
+
return files;
|
|
18
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One batch's contents: header (destination + overall progress + Cancel all/Close) and the
|
|
3
|
+
* per-file rows. Shared by the desktop floating card and the mobile sheet — only the outer
|
|
4
|
+
* chrome differs between them (see `upload-progress-panel.tsx`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { X } from "lucide-react";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { summarizeBatch } from "./upload-batch-progress";
|
|
10
|
+
import { UploadBatchRow } from "./upload-batch-row";
|
|
11
|
+
import type { UploadBatch } from "./upload-store";
|
|
12
|
+
import { useUploadStore } from "./upload-store";
|
|
13
|
+
|
|
14
|
+
function formatMb(bytes: number): string {
|
|
15
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UploadBatchCardProps {
|
|
19
|
+
batch: UploadBatch;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function UploadBatchCard({ batch }: UploadBatchCardProps) {
|
|
23
|
+
const cancelItem = useUploadStore((s) => s.cancelItem);
|
|
24
|
+
const cancelBatch = useUploadStore((s) => s.cancelBatch);
|
|
25
|
+
const dismissBatch = useUploadStore((s) => s.dismissBatch);
|
|
26
|
+
const summary = summarizeBatch(batch.items);
|
|
27
|
+
const settled = batch.settled;
|
|
28
|
+
const pct = summary.bytesTotal > 0 ? Math.round((summary.bytesLoaded / summary.bytesTotal) * 100) : 0;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="w-full overflow-hidden rounded-lg border border-border bg-panel shadow-lg" data-testid="upload-batch-card">
|
|
32
|
+
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
33
|
+
<div className="min-w-0 flex-1">
|
|
34
|
+
<p className="truncate text-sm font-medium text-text">
|
|
35
|
+
{settled
|
|
36
|
+
? `${summary.doneCount}/${summary.totalCount} uploaded${summary.failedCount > 0 ? `, ${summary.failedCount} failed` : ""}`
|
|
37
|
+
: `Uploading ${summary.totalCount} item${summary.totalCount === 1 ? "" : "s"}… ${pct}%`}
|
|
38
|
+
</p>
|
|
39
|
+
<p className="truncate text-xs text-text-2">{formatMb(summary.bytesLoaded)} / {formatMb(summary.bytesTotal)}</p>
|
|
40
|
+
</div>
|
|
41
|
+
{settled ? (
|
|
42
|
+
<Button variant="ghost" size="sm" className="h-11 shrink-0" onClick={() => dismissBatch(batch.id)}>
|
|
43
|
+
Close
|
|
44
|
+
</Button>
|
|
45
|
+
) : (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
aria-label="Cancel all"
|
|
49
|
+
onClick={() => cancelBatch(batch.id)}
|
|
50
|
+
className="flex size-11 shrink-0 items-center justify-center rounded-md can-hover:hover:bg-surface-elevated"
|
|
51
|
+
>
|
|
52
|
+
<X className="size-4 text-text-2" />
|
|
53
|
+
</button>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div className="max-h-64 divide-y divide-border overflow-y-auto">
|
|
58
|
+
{batch.items.map((item) => (
|
|
59
|
+
<UploadBatchRow key={item.id} item={item} onCancel={() => cancelItem(batch.id, item.id)} />
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure aggregation over one batch's items — overall bytes and counts for the panel header.
|
|
3
|
+
* Split out of the store/UI so the arithmetic (weighting in-flight bytes, treating "skipped"
|
|
4
|
+
* as complete) is unit-testable without zustand or a browser.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { UploadItem } from "./upload-store";
|
|
8
|
+
|
|
9
|
+
export interface BatchProgressSummary {
|
|
10
|
+
bytesLoaded: number;
|
|
11
|
+
bytesTotal: number;
|
|
12
|
+
/** Uploaded or intentionally skipped — both count toward "how much of the batch is over". */
|
|
13
|
+
doneCount: number;
|
|
14
|
+
failedCount: number;
|
|
15
|
+
totalCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function summarizeBatch(items: UploadItem[]): BatchProgressSummary {
|
|
19
|
+
let bytesLoaded = 0;
|
|
20
|
+
let bytesTotal = 0;
|
|
21
|
+
let doneCount = 0;
|
|
22
|
+
let failedCount = 0;
|
|
23
|
+
|
|
24
|
+
for (const item of items) {
|
|
25
|
+
bytesTotal += item.size;
|
|
26
|
+
bytesLoaded += item.state === "done" || item.state === "skipped"
|
|
27
|
+
? item.size
|
|
28
|
+
: Math.min(item.bytesLoaded, item.size);
|
|
29
|
+
if (item.state === "done" || item.state === "skipped") doneCount++;
|
|
30
|
+
if (item.state === "failed" || item.state === "cancelled") failedCount++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { bytesLoaded, bytesTotal, doneCount, failedCount, totalCount: items.length };
|
|
34
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One file row inside the upload progress panel/sheet: name, relative path, size, progress
|
|
3
|
+
* bar, state, and a per-row Cancel while it is still queued/uploading.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { AlertTriangle, Check, X } from "lucide-react";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import type { UploadItem } from "./upload-store";
|
|
9
|
+
|
|
10
|
+
function formatBytes(bytes: number): string {
|
|
11
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
12
|
+
const units = ["KB", "MB", "GB"];
|
|
13
|
+
let value = bytes / 1024;
|
|
14
|
+
let unitIndex = 0;
|
|
15
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
16
|
+
value /= 1024;
|
|
17
|
+
unitIndex++;
|
|
18
|
+
}
|
|
19
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UploadBatchRowProps {
|
|
23
|
+
item: UploadItem;
|
|
24
|
+
onCancel(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function UploadBatchRow({ item, onCancel }: UploadBatchRowProps) {
|
|
28
|
+
const pct = item.size > 0 ? Math.min(100, Math.round((item.bytesLoaded / item.size) * 100)) : 0;
|
|
29
|
+
const active = item.state === "queued" || item.state === "uploading";
|
|
30
|
+
const showsPath = item.relativePath !== item.name;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex flex-col gap-1 px-3 py-2" data-testid="upload-row" data-state={item.state}>
|
|
34
|
+
<div className="flex items-center gap-2">
|
|
35
|
+
<div className="min-w-0 flex-1">
|
|
36
|
+
<p className="truncate text-sm text-text">{item.name}</p>
|
|
37
|
+
{showsPath && <p className="truncate text-xs text-text-2">{item.relativePath}</p>}
|
|
38
|
+
</div>
|
|
39
|
+
<span className="shrink-0 text-xs text-text-2">{formatBytes(item.size)}</span>
|
|
40
|
+
{active ? (
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
aria-label={`Cancel ${item.name}`}
|
|
44
|
+
onClick={onCancel}
|
|
45
|
+
className="flex size-11 shrink-0 items-center justify-center rounded-md can-hover:hover:bg-surface-elevated"
|
|
46
|
+
>
|
|
47
|
+
<X className="size-4 text-text-2" />
|
|
48
|
+
</button>
|
|
49
|
+
) : (
|
|
50
|
+
<span className="flex size-11 shrink-0 items-center justify-center" aria-label={item.state}>
|
|
51
|
+
{item.state === "done" && <Check className="size-4 text-success" />}
|
|
52
|
+
{item.state === "skipped" && <span className="text-xs text-text-2">Skipped</span>}
|
|
53
|
+
{(item.state === "failed" || item.state === "cancelled") && (
|
|
54
|
+
<AlertTriangle className="size-4 text-destructive" />
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{active && (
|
|
61
|
+
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-elevated">
|
|
62
|
+
<div
|
|
63
|
+
className={cn(
|
|
64
|
+
"h-full rounded-full bg-primary transition-[width]",
|
|
65
|
+
item.state === "queued" && "w-0 opacity-40",
|
|
66
|
+
)}
|
|
67
|
+
style={item.state === "uploading" ? { width: `${pct}%` } : undefined}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{(item.state === "failed" || item.state === "cancelled") && (
|
|
73
|
+
<p className="text-xs text-destructive">
|
|
74
|
+
{item.state === "cancelled" ? "Cancelled" : item.errorMessage ?? "Upload failed"}
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns an upload failure into a short, human row message for the progress panel. A fresh
|
|
3
|
+
* 409/EEXIST never reaches here — `uploadResolvingCollisions` already resolves that through
|
|
4
|
+
* the collision prompt before the job can fail.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { FsError } from "@/lib/fs-api";
|
|
8
|
+
|
|
9
|
+
export function describeUploadError(error: unknown): string {
|
|
10
|
+
if (error instanceof FsError) {
|
|
11
|
+
if (error.status === 403) return "Not allowed here";
|
|
12
|
+
// Cloudflare's free-tier request-body cap when the app is reached through a tunnel — a
|
|
13
|
+
// client-observed HTTP status, not a code PPM's own upload route ever emits itself.
|
|
14
|
+
if (error.status === 413) return "File too large for the tunnel (100 MB)";
|
|
15
|
+
return error.message || "Upload failed";
|
|
16
|
+
}
|
|
17
|
+
if (error instanceof Error) return "Connection lost";
|
|
18
|
+
return "Upload failed";
|
|
19
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global upload progress surface — mounted once at the app root (`app.tsx`), reading straight
|
|
3
|
+
* from `upload-store.ts`. Replaces the old "Uploading N files… NN%" toast: desktop gets a
|
|
4
|
+
* persistent bottom-right card per batch (a normal fixed panel, not a modal — `z-30` keeps it
|
|
5
|
+
* under the floating-window band's own max of `z-38` and well under Radix's `z-50`); mobile
|
|
6
|
+
* collapses every batch behind a small pinned pill that opens a bottom sheet, so the keyboard
|
|
7
|
+
* dock and thumb zone stay clear.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { useShallow } from "zustand/react/shallow";
|
|
12
|
+
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
13
|
+
import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
|
|
14
|
+
import { UploadBatchCard } from "./upload-batch-card";
|
|
15
|
+
import { useUploadStore, type UploadBatch } from "./upload-store";
|
|
16
|
+
|
|
17
|
+
// `useShallow` keeps this selector's returned array reference-stable across renders that
|
|
18
|
+
// don't actually change any batch — the plain `.map().filter()` used before it built a new
|
|
19
|
+
// array identity on every call, and zustand's `useSyncExternalStore` subscription then saw
|
|
20
|
+
// a "changed" snapshot on every single render, looping forever ("Maximum update depth
|
|
21
|
+
// exceeded") the moment any batch existed.
|
|
22
|
+
function useActiveBatches(): UploadBatch[] {
|
|
23
|
+
return useUploadStore(
|
|
24
|
+
useShallow((s) => s.order.map((id) => s.batches[id]).filter((b): b is UploadBatch => b != null)),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function UploadProgressPanel() {
|
|
29
|
+
const isMobile = useIsMobile();
|
|
30
|
+
const batches = useActiveBatches();
|
|
31
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
32
|
+
|
|
33
|
+
if (batches.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
if (!isMobile) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className="fixed bottom-4 right-4 z-30 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
|
39
|
+
data-testid="upload-progress-panel"
|
|
40
|
+
>
|
|
41
|
+
{batches.map((batch) => (
|
|
42
|
+
<UploadBatchCard key={batch.id} batch={batch} />
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const uploading = batches.filter((b) => !b.settled).length;
|
|
49
|
+
const label = uploading > 0
|
|
50
|
+
? `Uploading ${batches.reduce((n, b) => n + b.items.length, 0)}…`
|
|
51
|
+
: "Uploads finished";
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={() => setSheetOpen(true)}
|
|
58
|
+
data-testid="upload-progress-pill"
|
|
59
|
+
className="fixed bottom-20 right-3 z-30 flex h-11 items-center gap-2 rounded-full border border-border bg-panel px-4 text-sm text-text shadow-lg"
|
|
60
|
+
>
|
|
61
|
+
{label}
|
|
62
|
+
</button>
|
|
63
|
+
<BottomSheet open={sheetOpen} onClose={() => setSheetOpen(false)} className="max-h-[70vh] overflow-y-auto p-2">
|
|
64
|
+
<div className="flex flex-col gap-2 px-2 pb-2">
|
|
65
|
+
{batches.map((batch) => (
|
|
66
|
+
<UploadBatchCard key={batch.id} batch={batch} />
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
</BottomSheet>
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|