@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-Bb-NNlp1.js → ai-resource-editor-C59ti0ED.js} +1 -1
  5. package/dist/web/assets/{audio-preview-DG2Tw0cO.js → audio-preview-GGksv6U8.js} +1 -1
  6. package/dist/web/assets/chat-tab-CM5hUNpk.js +15 -0
  7. package/dist/web/assets/code-editor-2yL9lCL1.js +10 -0
  8. package/dist/web/assets/{conflict-editor-B6hVffX3.js → conflict-editor-H1zmUXSQ.js} +1 -1
  9. package/dist/web/assets/{csv-preview-hL4Z9HS5.js → csv-preview-Dzzyx_Gv.js} +1 -1
  10. package/dist/web/assets/{database-viewer-CTK5kMaA.js → database-viewer-CiVA--mM.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-DZxnANY4.js → diff-viewer-69nl3mqO.js} +1 -1
  12. package/dist/web/assets/{docx-preview-CrAc7Qjv.js → docx-preview-CFvopEBf.js} +1 -1
  13. package/dist/web/assets/explorer-body-DyPXovdU.js +2 -0
  14. package/dist/web/assets/{explorer-window-content-NS8Y0v8c.js → explorer-window-content-CXFdvs_i.js} +1 -1
  15. package/dist/web/assets/{extension-webview-D6eywKPN.js → extension-webview-DIoKwGV4.js} +1 -1
  16. package/dist/web/assets/{git-log-panel-DAogZ9QV.js → git-log-panel-Copm8mzy.js} +1 -1
  17. package/dist/web/assets/{glide-data-grid-DxfJRN-r.js → glide-data-grid-D0L8SZVt.js} +4 -4
  18. package/dist/web/assets/group-chat-tab-D9cazEAI.js +1 -0
  19. package/dist/web/assets/{image-preview-Byjx8MoL.js → image-preview-vFeN8o43.js} +1 -1
  20. package/dist/web/assets/{index-CcN0TBm4.js → index-BewcuJw5.js} +24 -24
  21. package/dist/web/assets/index-oxsyfDa0.css +2 -0
  22. package/dist/web/assets/keybindings-store-ruNjhVT3.js +1 -0
  23. package/dist/web/assets/{markdown-renderer-CXlEe3ma.js → markdown-renderer-B46VJEHH.js} +2 -2
  24. package/dist/web/assets/markdown-renderer-CYiMPviS.js +1 -0
  25. package/dist/web/assets/{mobile-explorer-sheet-BuqBlP3o.js → mobile-explorer-sheet-BkMZxqs3.js} +1 -1
  26. package/dist/web/assets/notification-store-B3IK7PXf.js +1 -0
  27. package/dist/web/assets/{pdf-preview-F2DWVB85.js → pdf-preview-DU_NRMno.js} +1 -1
  28. package/dist/web/assets/{postgres-viewer-KZqPYfOB.js → postgres-viewer-C0h3Zfk9.js} +2 -2
  29. package/dist/web/assets/{settings-store-CY-4_U3H.js → settings-store-C9YT1Hir.js} +2 -2
  30. package/dist/web/assets/{settings-tab-Khj-w6Sr.js → settings-tab-Cep8gjxG.js} +1 -1
  31. package/dist/web/assets/{sql-query-editor-Cp0_e2P1.js → sql-query-editor-DJofTC9F.js} +1 -1
  32. package/dist/web/assets/{sqlite-viewer-CIv3sagZ.js → sqlite-viewer-D7bskeyk.js} +1 -1
  33. package/dist/web/assets/{system-monitor-tab-CrssTOf4.js → system-monitor-tab-CxgFsQx9.js} +1 -1
  34. package/dist/web/assets/team-member-sheet-Dn5NGkcp.js +2 -0
  35. package/dist/web/assets/{team-member-window-content-D-hXax3c.js → team-member-window-content-DS9V4trr.js} +1 -1
  36. package/dist/web/assets/{terminal-tab-BIQDG-Ti.js → terminal-tab-CMs8AyFJ.js} +3 -3
  37. package/dist/web/assets/tool-cards-HHRTIlFv.js +8 -0
  38. package/dist/web/assets/{use-monaco-theme-Djp1_fKi.js → use-monaco-theme-BLt-n9r1.js} +1 -1
  39. package/dist/web/assets/use-open-team-member-BwtzTuxf.js +1 -0
  40. package/dist/web/assets/{use-websocket-CCYA9-a2.js → use-websocket-DA3Zwl3Z.js} +1 -1
  41. package/dist/web/assets/video-preview-BZhTYMFX.js +1 -0
  42. package/dist/web/index.html +3 -3
  43. package/dist/web/sw.js +1 -1
  44. package/package.json +1 -1
  45. package/src/web/app.tsx +12 -0
  46. package/src/web/components/chat/chat-tab.tsx +11 -0
  47. package/src/web/components/chat/team-activity-panel.tsx +4 -4
  48. package/src/web/components/chat/team-member-activity-format.ts +38 -0
  49. package/src/web/components/chat/team-member-activity-row.tsx +1 -29
  50. package/src/web/components/chat/team-member-sheet.tsx +47 -0
  51. package/src/web/components/chat/team-working-bar.tsx +62 -0
  52. package/src/web/components/chat/use-open-team-member.ts +41 -0
  53. package/src/web/components/os-explorer/actions/collision-prompt-queue.ts +133 -0
  54. package/src/web/components/os-explorer/actions/explorer-actions-clipboard.ts +29 -3
  55. package/src/web/components/os-explorer/actions/explorer-actions-upload.ts +88 -33
  56. package/src/web/components/os-explorer/actions/use-collision-prompt.ts +56 -0
  57. package/src/web/components/os-explorer/actions/use-explorer-actions.ts +29 -44
  58. package/src/web/components/os-explorer/actions/use-permanent-overwrite-prompt.ts +47 -0
  59. package/src/web/components/os-explorer/dnd/use-drop-transfer.tsx +19 -27
  60. package/src/web/components/os-explorer/explorer-dialogs.tsx +12 -1
  61. package/src/web/components/os-explorer/upload/capture-picked-files.ts +18 -0
  62. package/src/web/components/os-explorer/upload/upload-batch-card.tsx +64 -0
  63. package/src/web/components/os-explorer/upload/upload-batch-progress.ts +34 -0
  64. package/src/web/components/os-explorer/upload/upload-batch-row.tsx +79 -0
  65. package/src/web/components/os-explorer/upload/upload-error-message.ts +19 -0
  66. package/src/web/components/os-explorer/upload/upload-progress-panel.tsx +72 -0
  67. package/src/web/components/os-explorer/upload/upload-store.ts +109 -0
  68. package/src/web/components/os-explorer/upload/use-upload-picker.tsx +7 -5
  69. package/src/web/lib/fs-upload-xhr.ts +13 -2
  70. package/dist/web/assets/chat-tab-CgAv-crh.js +0 -15
  71. package/dist/web/assets/code-editor-bX4jlgMy.js +0 -10
  72. package/dist/web/assets/explorer-body-DykJXCTw.js +0 -2
  73. package/dist/web/assets/group-chat-tab-DkVi6qL2.js +0 -1
  74. package/dist/web/assets/index-Bucbpby-.css +0 -2
  75. package/dist/web/assets/keybindings-store-DJIHqbLM.js +0 -1
  76. package/dist/web/assets/markdown-renderer-pYznJDbh.js +0 -1
  77. package/dist/web/assets/notification-store-Bcl5efY2.js +0 -1
  78. package/dist/web/assets/tool-cards-Dx-Rrpri.js +0 -8
  79. package/dist/web/assets/video-preview-cvjNMjWA.js +0 -1
  80. /package/dist/web/assets/{api-themes-CEyYK7AK.js → api-themes-8WTH6KpV.js} +0 -0
  81. /package/dist/web/assets/{can-open-in-ppm-Cu-XYIgu.js → can-open-in-ppm-DXGOSUQc.js} +0 -0
  82. /package/dist/web/assets/{csv-parser-r-Nk1oHT.js → csv-parser-BRtnJCfv.js} +0 -0
  83. /package/dist/web/assets/{edit-diff-preview-HTZPC9VP.js → edit-diff-preview-BivQwZMD.js} +0 -0
  84. /package/dist/web/assets/{lib-BzxL1ARZ.js → lib-B1clP4ai.js} +0 -0
  85. /package/dist/web/assets/{media-url-C5vA0ha9.js → media-url-CUpN5vI1.js} +0 -0
  86. /package/dist/web/assets/{run-in-terminal-B8TDtaM_.js → run-in-terminal-CGW6o37_.js} +0 -0
  87. /package/dist/web/assets/{send-to-chat-CuBAVC8l.js → send-to-chat-YXHdAyLG.js} +0 -0
  88. /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 the three
3
- * interruptions they need: the collision prompt, the permanent-delete confirmation and
4
- * the properties dialog.
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: (CollisionRequest & { resolve(choice: CollisionChoice): void }) | null;
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: { name: string; resolve(proceed: boolean): void } | null;
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 [collision, setCollision] = useState<ExplorerDialogState["collision"]>(null);
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
- const context = useMemo(
85
- () => ({
86
- get sep() {
87
- return sliceRef.current?.sep ?? "/";
88
- },
89
- resolve: (request: CollisionRequest) =>
90
- new Promise<CollisionChoice>((resolve) => {
91
- setCollision({
92
- ...request,
93
- resolve: (choice) => {
94
- setCollision(null);
95
- resolve(choice);
96
- },
97
- });
98
- }),
99
- confirmPermanentOverwrite: (name: string) =>
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, useState, type ReactNode } from "react";
12
- import type { CollisionChoice, CollisionRequest, TransferContext } from "../actions/explorer-actions-clipboard";
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 type { ExplorerDialogState } from "../actions/use-explorer-actions";
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 [collision, setCollision] = useState<ExplorerDialogState["collision"]>(null);
29
- const [permanentOverwrite, setPermanentOverwrite] = useState<ExplorerDialogState["permanentOverwrite"]>(null);
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: CollisionRequest) =>
43
- new Promise<CollisionChoice>((resolve) => {
44
- setCollision({
45
- ...request,
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
+ }