@sinequa/atomic-angular 1.16.0 → 1.17.0

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.
@@ -132,8 +132,8 @@ function withDocumentCountAdjusted(workspaces, id, delta) {
132
132
  * touches the cache once the server has answered, so a rejected call leaves
133
133
  * the state as it was — the call site reports the error.
134
134
  *
135
- * `addDocuments` alone stays purely local: it is the upload flow's optimistic
136
- * pre-transfer cache update.
135
+ * `addDocuments`, `discardDocuments` and `patchDocument` stay purely local. The upload
136
+ * flow no longer uses them: the list is the index, and only the listing writes to it.
137
137
  */
138
138
  const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Workspaces"), withState(initialState$1), withComputed(({ workspaces, pagination, selectedIds }) => ({
139
139
  hasMore: computed(() => workspaces().length < pagination().total && workspaces().length < MAX_LOADED_WORKSPACES),
@@ -353,17 +353,16 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
353
353
  return merged;
354
354
  },
355
355
  /**
356
- * Local only — the upload flow renders a document optimistically, before
357
- * its bytes are even sent; the server persists it separately once the
358
- * transfer completes.
356
+ * Local only: adds documents to the cache without calling the API. The upload
357
+ * flow does not use it (a file enters the list once the listing returns it).
359
358
  */
360
359
  addDocuments(workspaceId, documents) {
361
360
  setDocuments(workspaceId, [...(store.documents()[workspaceId] ?? []), ...documents]);
362
361
  adjustDocumentCount(workspaceId, documents.length);
363
362
  },
364
363
  /**
365
- * Local only, the counterpart of {@link addDocuments}: forgets documents the
366
- * server never persisted — an upload cancelled before it finished.
364
+ * Local only, the counterpart of {@link addDocuments}: forgets documents without
365
+ * calling the API.
367
366
  * `removeDocuments` is the one that deletes on the server.
368
367
  */
369
368
  discardDocuments(workspaceId, documentIds) {
@@ -372,7 +371,7 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
372
371
  setDocuments(workspaceId, after);
373
372
  adjustDocumentCount(workspaceId, after.length - before.length);
374
373
  },
375
- /** Replaces one cached document (status/progress updates coming from the upload flow), matched by id. */
374
+ /** Local only: merges a patch into one cached document, matched by id. */
376
375
  patchDocument(workspaceId, documentId, patch) {
377
376
  patchState(store, state => ({
378
377
  documents: {
@@ -437,7 +436,8 @@ const initialState = {
437
436
  workspaces: [],
438
437
  connection: null,
439
438
  clearedCount: 0,
440
- jobIndexing: {}
439
+ jobIndexing: {},
440
+ ignoredJobs: {}
441
441
  };
442
442
  const FINISHED = ["done", "error", "rejected"];
443
443
  const FAILED = ["error", "rejected"];
@@ -493,6 +493,45 @@ function phaseProgress(uploads, jobIndexing) {
493
493
  function updateOne(uploads, id, change) {
494
494
  return uploads.map(upload => (upload.id === id ? change(upload) : upload));
495
495
  }
496
+ /** `ignored` plus the jobs of `uploads`. */
497
+ function withIgnored(ignored, uploads) {
498
+ const next = { ...ignored };
499
+ for (const upload of uploads)
500
+ next[jobOf(upload)] = true;
501
+ return next;
502
+ }
503
+ /** The row standing for a whole job, whose files the API does not list: `<jobId>/`. */
504
+ const remoteJobRowId = (jobId) => `${jobId}/`;
505
+ /** Where a whole job stands, as one row: what the API says of its two phases. */
506
+ function remoteJobState(job) {
507
+ if (job.uploadStatus === "uploading")
508
+ return { status: "uploading", percent: job.uploadProgress?.percentage ?? 0 };
509
+ if (job.uploadStatus === "upload_interrupted")
510
+ return { status: "error", percent: 0 };
511
+ if (job.indexingStatus === "complete")
512
+ return { status: "done", percent: 100 };
513
+ if (job.indexingStatus === "failed" || job.indexingStatus === "interrupted")
514
+ return { status: "error", percent: 0 };
515
+ // The job's aggregate indexing percentage lives in `jobIndexing`: no file has its own.
516
+ return { status: "indexing", percent: 0 };
517
+ }
518
+ /**
519
+ * The rows a job started elsewhere shows as: one per file the API names (errors and
520
+ * rejections, the only per-file data it has), and one for the job itself — unless a
521
+ * named file already explains why it failed.
522
+ */
523
+ function remoteRows({ job, workspaceId, mine }) {
524
+ const base = { type: "doc", workspaceId, retryable: false, percent: 0, remote: { mine } };
525
+ const files = [
526
+ ...(job.errors ?? []).map(e => ({ ...base, id: `${job.jobId}/${e.fileName}`, name: e.fileName, status: "error", message: e.error ?? undefined })),
527
+ ...(job.rejected ?? []).map(r => ({ ...base, id: `${job.jobId}/${r.fileName}`, name: r.fileName, status: "rejected", message: r.reason }))
528
+ ];
529
+ const state = remoteJobState(job);
530
+ if (state.status === "error" && files.length)
531
+ return files;
532
+ return [{ ...base, ...state, id: remoteJobRowId(job.jobId), name: "" }, ...files];
533
+ }
534
+ const sameRow = (a, b) => a.status === b.status && a.percent === b.percent && a.message === b.message && a.workspaceId === b.workspaceId && a.remote?.mine === b.remote?.mine;
496
535
  /**
497
536
  * The state of every upload the tracker follows — pure state, no I/O.
498
537
  *
@@ -515,6 +554,13 @@ const UploadTrackerStore = signalStore({ providedIn: "root" }, withDevtools("Upl
515
554
  }),
516
555
  /** Everything is over and nothing failed: the panel celebrates, then dismisses itself. */
517
556
  allDone: computed(() => uploads().length > 0 && uploads().every(u => u.status === "done")),
557
+ /** Everything is over, whether it worked or not: nothing moves any more, and the panel may be closed. */
558
+ settled: computed(() => uploads().length > 0 && uploads().every(isFinished)),
559
+ /**
560
+ * A failure on an upload of the current user's. It is theirs to read and close: the panel
561
+ * stays until they do. A failure on someone else's upload is only worth the usual delay.
562
+ */
563
+ authorFailed: computed(() => uploads().some(u => isFailed(u) && (!u.remote || u.remote.mine))),
518
564
  /** Per workspace, how far each phase is — what the tracker's progress bars show. */
519
565
  progress: computed(() => {
520
566
  const byWorkspace = new Map();
@@ -638,22 +684,62 @@ const UploadTrackerStore = signalStore({ providedIn: "root" }, withDevtools("Upl
638
684
  patchState(store, { uploads: next, jobIndexing });
639
685
  return changed;
640
686
  },
687
+ /**
688
+ * Feeds in the jobs the status API reports that this client is not following — the ones
689
+ * another user started, or its own from before a page reload. Each becomes a row (see
690
+ * `remoteRows`), updated on every call; a job this client sent itself is left to the
691
+ * local rows, and so is one the user already got rid of.
692
+ *
693
+ * @returns Whether anything changed, which paces the poller.
694
+ */
695
+ applyRemoteJobs(entries) {
696
+ const current = store.uploads();
697
+ const ignored = store.ignoredJobs();
698
+ const ownJobs = new Set(current.filter(u => !u.remote).map(jobOf));
699
+ const rows = new Map(current.map(u => [u.id, u]));
700
+ const workspaces = [...store.workspaces()];
701
+ let changed = false;
702
+ for (const entry of entries) {
703
+ if (ignored[entry.job.jobId] || ownJobs.has(entry.job.jobId))
704
+ continue;
705
+ for (const row of remoteRows(entry)) {
706
+ const known = rows.get(row.id);
707
+ if (known && sameRow(known, row))
708
+ continue;
709
+ rows.set(row.id, known ? { ...known, ...row } : row);
710
+ changed = true;
711
+ }
712
+ if (!workspaces.some(w => w.id === entry.workspaceId)) {
713
+ workspaces.push({ id: entry.workspaceId, name: entry.workspaceName });
714
+ changed = true;
715
+ }
716
+ }
717
+ if (changed)
718
+ patchState(store, { uploads: [...rows.values()], workspaces });
719
+ return changed;
720
+ },
641
721
  setConnection(connection) {
642
722
  patchState(store, { connection });
643
723
  },
644
724
  remove(id) {
645
- patchState(store, state => ({ uploads: state.uploads.filter(u => u.id !== id) }));
725
+ patchState(store, state => {
726
+ const uploads = state.uploads.filter(u => u.id !== id);
727
+ // The job is forgotten once its last row is gone; while one is left, the job is still followed.
728
+ const gone = state.uploads.filter(u => u.id === id && !uploads.some(other => jobOf(other) === jobOf(u)));
729
+ return { uploads, ignoredJobs: withIgnored(state.ignoredJobs, gone) };
730
+ });
646
731
  },
647
732
  /** Drops a finished workspace's files from the tracker; they stay counted as indexed. */
648
733
  removeWorkspace(workspaceId) {
649
734
  patchState(store, state => ({
650
735
  uploads: state.uploads.filter(u => u.workspaceId !== workspaceId),
651
736
  workspaces: state.workspaces.filter(w => w.id !== workspaceId),
652
- clearedCount: state.clearedCount + state.uploads.filter(u => u.workspaceId === workspaceId).length
737
+ clearedCount: state.clearedCount + state.uploads.filter(u => u.workspaceId === workspaceId).length,
738
+ ignoredJobs: withIgnored(state.ignoredJobs, state.uploads.filter(u => u.workspaceId === workspaceId))
653
739
  }));
654
740
  },
655
741
  reset() {
656
- patchState(store, initialState);
742
+ patchState(store, state => ({ ...initialState, ignoredJobs: withIgnored(state.ignoredJobs, state.uploads) }));
657
743
  }
658
744
  })));
659
745
 
@@ -744,6 +830,12 @@ function isJobTerminal(job) {
744
830
  function allJobs(res) {
745
831
  return (res.containers ?? []).flatMap(c => c.jobs ?? []);
746
832
  }
833
+ /** Like {@link allJobs}, but keeps the workspace each job belongs to. */
834
+ function containerJobs(res) {
835
+ return (res.containers ?? []).flatMap(c => (c.jobs ?? []).map(job => ({ job, containerId: c.containerId, containerName: c.containerName })));
836
+ }
837
+ /** How long the panel stays on screen once every upload is over. */
838
+ const FINISHED_UPLOAD_VISIBLE_MS = 2 * 60 * 1000;
747
839
 
748
840
  /**
749
841
  * `crypto.randomUUID()` is only available in a secure context (HTTPS, or HTTP on
@@ -969,9 +1061,6 @@ const UPLOAD_FACTORY = new InjectionToken("UPLOAD_FACTORY", {
969
1061
  factory: () => createTusUpload
970
1062
  });
971
1063
 
972
- // One page is enough to refresh what the open workspace shows: the detail view
973
- // itself loads page 1 at the same size.
974
- const DOCUMENT_STATUS_PAGE_SIZE = 100;
975
1064
  /**
976
1065
  * Sends files to a workspace and follows them until they are indexed.
977
1066
  *
@@ -986,6 +1075,10 @@ const DOCUMENT_STATUS_PAGE_SIZE = 100;
986
1075
  * speaks for both. What the user sees lives in `UploadTrackerStore`; this
987
1076
  * service only writes to it.
988
1077
  *
1078
+ * It never writes a file into a workspace's list: the list is the index, and a file
1079
+ * enters it only once the container listing returns it. What the service does for the
1080
+ * list is re-read that listing while something is in progress.
1081
+ *
989
1082
  * Root-scoped: an upload must outlive the view that started it. It registers its
990
1083
  * window and auth listeners once, and removes them when the application is destroyed.
991
1084
  */
@@ -1053,37 +1146,20 @@ class UploadService {
1053
1146
  const jobId = generateUUID();
1054
1147
  const fileCount = files.length;
1055
1148
  const totalBytes = files.reduce((total, f) => total + f.size, 0);
1056
- const uploadedBy = this.currentUser.user()?.name;
1057
- const now = new Date().toISOString();
1058
- const documents = files.map(file => ({
1059
- // A provisional id, which is also the key the tracker and this service follow the
1060
- // file by. It is NOT the server's: the listing names a document
1061
- // `/DocUpload/…|<containerId>/<fileName>`, and that is what DELETE and move expect.
1062
- // The first listing that shows the file swaps it in (see `adoptServerId`), so until
1063
- // then the document has no real id to act on.
1064
- id: `${jobId}/${file.name}`,
1065
- name: file.name,
1066
- workspaceId: workspace.id,
1067
- type: getDocumentType(file.name),
1068
- createdAt: now,
1069
- size: file.size,
1070
- status: "uploading",
1071
- percent: 0,
1072
- jobId,
1073
- uploadedBy
1074
- }));
1075
- this.workspaces.addDocuments(workspace.id, documents);
1076
- this.tracker.track(documents.map(d => ({ id: d.id, name: d.name, type: d.type })), workspace.id, workspace.name);
1077
- documents.forEach((document, i) => {
1078
- this.inFlight.add(document.id);
1079
- this.startUpload(document, files[i], { jobId, fileCount, totalBytes }, options.endpoint);
1149
+ // The id is the key the tracker and this service follow the file by. It is NOT the
1150
+ // server's: the listing names a document `/DocUpload/…|<containerId>/<fileName>`.
1151
+ const items = files.map(file => ({ id: `${jobId}/${file.name}`, workspaceId: workspace.id }));
1152
+ this.tracker.track(files.map((file, i) => ({ id: items[i].id, name: file.name, type: getDocumentType(file.name) })), workspace.id, workspace.name);
1153
+ items.forEach((item, i) => {
1154
+ this.inFlight.add(item.id);
1155
+ this.startUpload(item, files[i], { jobId, fileCount, totalBytes }, options.endpoint);
1080
1156
  });
1081
1157
  this.poller.start();
1082
- return documents.map(d => d.id);
1158
+ return items.map(item => item.id);
1083
1159
  }
1084
1160
  /**
1085
1161
  * Abandons a transfer and forgets it: stops it, and drops the file from the
1086
- * tracker and from the workspace's cached documents. Works while it is sending
1162
+ * tracker. Works while it is sending
1087
1163
  * and once it has failed; once the bytes are in, indexing is the server's and
1088
1164
  * there is nothing left to cancel.
1089
1165
  *
@@ -1100,7 +1176,6 @@ class UploadService {
1100
1176
  this.inFlight.delete(id);
1101
1177
  this.clearRetryNotice(id);
1102
1178
  this.tracker.remove(id);
1103
- this.workspaces.discardDocuments(entry.workspaceId, [this.storedId(entry.workspaceId, id)]);
1104
1179
  try {
1105
1180
  await entry.handle.abort(true);
1106
1181
  }
@@ -1115,7 +1190,6 @@ class UploadService {
1115
1190
  if (!entry || entry.refused || upload?.status !== "error")
1116
1191
  return;
1117
1192
  this.tracker.restart(id);
1118
- this.patchStored(entry.workspaceId, id, { status: "uploading", percent: 0 });
1119
1193
  this.inFlight.add(id);
1120
1194
  entry.handle.resume();
1121
1195
  this.poller.start();
@@ -1132,17 +1206,15 @@ class UploadService {
1132
1206
  }
1133
1207
  /**
1134
1208
  * Tells the service which workspace is open, so the status polling can be
1135
- * scoped to it (the API documents that as the cheaper lookup) and refresh what
1136
- * it shows. `null` when none is. Starts the polling when the workspace holds
1137
- * documents that are not finished yet: a page reload loses every local tus
1138
- * handle, so the listing is the only way to know.
1209
+ * scoped to it (the API documents that as the cheaper lookup) and re-read its
1210
+ * listing. `null` when none is. Starts the polling: a page reload loses every
1211
+ * local tus handle and the listing no longer says what is in progress, so the
1212
+ * status API is the only way to know. The loop stops by itself after one tick
1213
+ * when nothing is.
1139
1214
  */
1140
1215
  watch(workspaceId) {
1141
1216
  this.focusedWorkspaceId = workspaceId;
1142
- if (!workspaceId)
1143
- return;
1144
- const unfinished = this.workspaces.documentsOf(workspaceId).some(d => d.status === "uploading" || d.status === "indexing");
1145
- if (unfinished)
1217
+ if (workspaceId)
1146
1218
  this.poller.start();
1147
1219
  }
1148
1220
  startPolling() {
@@ -1152,11 +1224,11 @@ class UploadService {
1152
1224
  this.poller.stop();
1153
1225
  }
1154
1226
  // ── sending ──
1155
- startUpload(document, file, job, endpoint) {
1156
- const workspaceId = document.workspaceId;
1227
+ startUpload(item, file, job, endpoint) {
1228
+ const workspaceId = item.workspaceId;
1157
1229
  if (!endpoint) {
1158
- this.inFlight.delete(document.id);
1159
- this.markError(document);
1230
+ this.inFlight.delete(item.id);
1231
+ this.markError(item);
1160
1232
  return;
1161
1233
  }
1162
1234
  const entry = {
@@ -1178,25 +1250,25 @@ class UploadService {
1178
1250
  // again once the user is back.
1179
1251
  void this.recoverSessionAndResume();
1180
1252
  if (this.signedOut) {
1181
- this.clearRetryNotice(document.id);
1253
+ this.clearRetryNotice(item.id);
1182
1254
  return false;
1183
1255
  }
1184
1256
  }
1185
1257
  else if (FATAL_UPLOAD_STATUSES.has(status)) {
1186
1258
  entry.refused = true;
1187
- this.clearRetryNotice(document.id);
1259
+ this.clearRetryNotice(item.id);
1188
1260
  return false;
1189
1261
  }
1190
1262
  // tus waits retryDelays[attempt] before this attempt and passes that same
1191
1263
  // index here, so the banner's countdown is the real delay, not an estimate.
1192
- this.noteRetryScheduled(document.id, attempt);
1264
+ this.noteRetryScheduled(item.id, attempt);
1193
1265
  return true;
1194
1266
  },
1195
1267
  // Anything that came back cleanly means the connection works again for this
1196
1268
  // upload, whatever the retry bookkeeping still says.
1197
1269
  responded: status => {
1198
1270
  if (status < 400)
1199
- this.noteHealthy(document.id);
1271
+ this.noteHealthy(item.id);
1200
1272
  },
1201
1273
  // A recovery kicked off by a previous 401 may still be in flight, and
1202
1274
  // carrying the token it is fetching is the entire point of this retry.
@@ -1209,67 +1281,42 @@ class UploadService {
1209
1281
  // processed: on a fast transfer a stale event can still arrive after
1210
1282
  // success moved the file to indexing. Drop anything that comes once the
1211
1283
  // upload phase is over, so it cannot stomp the indexing percentage.
1212
- if (this.tracker.uploads().find(u => u.id === document.id)?.status !== "uploading")
1284
+ if (this.tracker.uploads().find(u => u.id === item.id)?.status !== "uploading")
1213
1285
  return;
1214
- this.tracker.setProgress(document.id, bytesTotal ? (bytesUploaded / bytesTotal) * 100 : 0);
1286
+ this.tracker.setProgress(item.id, bytesTotal ? (bytesUploaded / bytesTotal) * 100 : 0);
1215
1287
  },
1216
1288
  success: () => {
1217
1289
  // Bytes are in; indexing is now the server's business, so the poller owns
1218
1290
  // this document from here. Nothing left to resume.
1219
- this.noteHealthy(document.id);
1220
- this.uploads.delete(document.id);
1221
- this.markIndexing(document);
1291
+ this.noteHealthy(item.id);
1292
+ this.uploads.delete(item.id);
1293
+ this.markIndexing(item);
1222
1294
  this.poller.start();
1223
1295
  },
1224
1296
  failed: () => {
1225
1297
  // The handle is kept: the retry budget is spent, but the upload is not lost —
1226
1298
  // `retry()` can pick it up again and tus resumes from the server's offset.
1227
- this.clearRetryNotice(document.id);
1228
- this.inFlight.delete(document.id);
1229
- this.markError(document, { retryable: !entry.refused });
1299
+ this.clearRetryNotice(item.id);
1300
+ this.inFlight.delete(item.id);
1301
+ this.markError(item, { retryable: !entry.refused });
1230
1302
  }
1231
1303
  }
1232
1304
  })
1233
1305
  };
1234
- this.uploads.set(document.id, entry);
1306
+ this.uploads.set(item.id, entry);
1235
1307
  entry.handle.begin();
1236
1308
  }
1237
- // ── what a document's state change writes where ──
1238
- // A document has two names. The tracker, `inFlight` and `uploads` know it by its
1239
- // provisional key (`<jobId>/<fileName>`, which never changes); the workspace store
1240
- // knows it by whatever id it holds right now — the same key until the server's id
1241
- // is adopted, the server's after. These helpers bridge the two.
1242
- /** The key a cached document is followed by in the tracker. */
1243
- keyOf(document) {
1244
- return document.jobId ? `${document.jobId}/${document.name}` : document.id;
1245
- }
1246
- /** The id the workspace store holds for the file followed under `key`. */
1247
- storedId(workspaceId, key) {
1248
- return this.workspaces.documentsOf(workspaceId).find(d => this.keyOf(d) === key)?.id ?? key;
1249
- }
1250
- patchStored(workspaceId, key, patch) {
1251
- this.workspaces.patchDocument(workspaceId, this.storedId(workspaceId, key), patch);
1252
- }
1253
- markIndexing(document) {
1254
- const key = this.keyOf(document);
1255
- this.patchStored(document.workspaceId, key, { status: "indexing" });
1256
- this.tracker.setIndexing(key);
1257
- }
1258
- markComplete(document) {
1259
- const key = this.keyOf(document);
1260
- this.patchStored(document.workspaceId, key, { status: null });
1261
- this.tracker.setDone(key);
1262
- }
1263
- markError(document, options = {}) {
1264
- const key = this.keyOf(document);
1265
- this.patchStored(document.workspaceId, key, { status: "error" });
1266
- this.tracker.setError(key, options);
1309
+ // ── what a file's state change writes ──
1310
+ markIndexing(item) {
1311
+ this.tracker.setIndexing(item.id);
1312
+ }
1313
+ markError(item, options = {}) {
1314
+ this.tracker.setError(item.id, options);
1267
1315
  }
1268
1316
  // ── following indexing ──
1269
1317
  //
1270
- // There are two endpoints, at two grains, and both are needed:
1271
- // - GET .../container/{id}/documents -> status + percent PER DOCUMENT
1272
- // - GET .../document-upload/status -> aggregate, errors[] and rejected[] PER JOB
1318
+ // - GET .../document-upload/status -> the tracker: aggregate, errors[] and rejected[] PER JOB
1319
+ // - GET .../container/{id}/documents -> the workspace's list, which is the index and nothing else
1273
1320
  async pollUploadStatus(signal) {
1274
1321
  try {
1275
1322
  const result = await this.runPollTick(signal);
@@ -1294,84 +1341,51 @@ class UploadService {
1294
1341
  // the tracker still follows a batch after the user navigates away from it.
1295
1342
  const response = await fetchUploadStatus(workspaceId ? { containerId: workspaceId } : {}, { signal });
1296
1343
  const jobs = allJobs(response);
1297
- // Both halves can move on their own — a rejection can surface with no workspace
1298
- // open at all — so the tick reports the union, otherwise the idle backoff would
1299
- // widen while things are still happening.
1300
- let changed = this.tracker.applyJobs(jobs);
1344
+ // Jobs started elsewhere come first, so that `applyJobs` finds their rows to update.
1345
+ const remote = await this.remoteJobs(response, signal);
1346
+ let changed = this.tracker.applyRemoteJobs(remote);
1347
+ changed = this.tracker.applyJobs(jobs) || changed;
1301
1348
  this.settleFinishedUploads();
1302
- let pending = jobs.some(job => !isJobTerminal(job));
1303
- if (workspaceId) {
1304
- const listing = await listContainerDocuments(workspaceId, { page: 1, pageSize: DOCUMENT_STATUS_PAGE_SIZE }, { signal });
1305
- const applied = this.applyDocumentStatuses(workspaceId, listing.documents);
1306
- pending = pending || applied.pending;
1307
- changed = changed || applied.changed;
1308
- }
1349
+ const pending = jobs.some(job => !isJobTerminal(job));
1350
+ // The list follows the index, not the status: a file shows up once the listing returns it.
1351
+ // The last tick of a loop reads it too, which is when a file that just got indexed comes in.
1352
+ if (workspaceId)
1353
+ changed = (await this.refreshListing(workspaceId, signal)) || changed;
1309
1354
  return { pending: pending || this.inFlight.size > 0, changed };
1310
1355
  }
1311
1356
  /**
1312
- * The job-level status can finish a file the document listing has not reported
1313
- * (no workspace open, or a listing that lags): mirror what the tracker now says
1314
- * onto the cached document and release the poller's hold on it.
1357
+ * The jobs of the response the panel should show although this client did not send
1358
+ * them. The server decides which ones the response holds — those in progress or just
1359
+ * ended, on the workspaces the user can write to — so none is filtered by role or date
1360
+ * here; only, with a workspace open, those that belong to it are kept. Whether the user
1361
+ * started one is read from `initiatedBy`, which is their `userId`, so it holds after a
1362
+ * page reload too.
1315
1363
  */
1316
- settleFinishedUploads() {
1317
- for (const upload of this.tracker.uploads()) {
1318
- if (upload.status !== "done" || !this.inFlight.has(upload.id))
1364
+ async remoteJobs(response, signal) {
1365
+ const me = (await this.currentUser.whenReady())?.id;
1366
+ if (signal.aborted)
1367
+ return [];
1368
+ const entries = [];
1369
+ for (const { job, containerId, containerName } of containerJobs(response)) {
1370
+ if (this.focusedWorkspaceId && this.focusedWorkspaceId !== containerId)
1319
1371
  continue;
1320
- this.inFlight.delete(upload.id);
1321
- this.patchStored(upload.workspaceId, upload.id, { status: null });
1372
+ const workspaceName = this.workspaces.workspace(containerId)?.name ?? containerName;
1373
+ entries.push({ job, workspaceId: containerId, workspaceName, mine: !!me && job.initiatedBy === me });
1322
1374
  }
1375
+ return entries;
1323
1376
  }
1324
- applyDocumentStatuses(workspaceId, wireDocuments) {
1325
- const locals = this.workspaces.documentsOf(workspaceId);
1326
- let pending = false;
1327
- let changed = false;
1328
- for (const wire of wireDocuments) {
1329
- const next = mapDocumentStatus(wire.status);
1330
- if (next === "uploading" || next === "indexing")
1331
- pending = true;
1332
- // The listing names a document `/DocUpload/…|<containerId>/<fileName>`, ours is
1333
- // `<jobId>/<fileName>`: the (job, name) pair is what both sides agree on.
1334
- const document = locals.find(d => d.id === wire.id || (!!wire.jobId && d.jobId === wire.jobId && d.name === wire.name));
1335
- if (!document)
1336
- continue;
1337
- const key = this.keyOf(document);
1338
- // From now on the store holds the id the server will recognise: delete and move send it.
1339
- if (document.id !== wire.id)
1340
- this.workspaces.patchDocument(workspaceId, document.id, { id: wire.id });
1341
- // While tus is still sending, the local byte-level progress is ahead of anything
1342
- // the server can report: it owns the percentage until tus hands over. An error
1343
- // is the one thing worth taking early.
1344
- if (this.inFlight.has(key) && document.status === "uploading") {
1345
- if (next === "error") {
1346
- this.inFlight.delete(key);
1347
- this.markError(document);
1348
- changed = true;
1349
- }
1350
- continue;
1351
- }
1352
- if (next === "indexing" && document.status !== "indexing") {
1353
- this.markIndexing(document);
1354
- changed = true;
1355
- }
1356
- else if (next === null && document.status !== null) {
1357
- this.inFlight.delete(key);
1358
- this.markComplete(document);
1359
- changed = true;
1360
- }
1361
- else if (next === "error" && document.status !== "error") {
1362
- this.inFlight.delete(key);
1363
- this.markError(document);
1364
- changed = true;
1365
- }
1366
- else if (next === "uploading" && typeof wire.percent === "number" && document.percent !== wire.percent) {
1367
- // Not ours to transfer (another user's upload, or our own seen again after a
1368
- // reload): the server's percentage is all there is.
1369
- this.workspaces.patchDocument(workspaceId, wire.id, { percent: wire.percent });
1370
- this.tracker.setProgress(key, wire.percent);
1371
- changed = true;
1372
- }
1377
+ /** Re-reads the workspace's listing. @returns Whether it differs from what was shown. */
1378
+ async refreshListing(workspaceId, signal) {
1379
+ const before = JSON.stringify(this.workspaces.documentsOf(workspaceId));
1380
+ await this.workspaces.loadDocuments(workspaceId, signal);
1381
+ return JSON.stringify(this.workspaces.documentsOf(workspaceId)) !== before;
1382
+ }
1383
+ /** The job-level status can finish a file this client was still holding the poller for: release it. */
1384
+ settleFinishedUploads() {
1385
+ for (const upload of this.tracker.uploads()) {
1386
+ if (upload.status === "done")
1387
+ this.inFlight.delete(upload.id);
1373
1388
  }
1374
- return { pending, changed };
1375
1389
  }
1376
1390
  // ── connection loss & session recovery ──
1377
1391
  //
@@ -3147,6 +3161,7 @@ class WorkspaceList {
3147
3161
  createRequested = output();
3148
3162
  store = inject(WorkspacesStore);
3149
3163
  dialogs = inject(DialogService);
3164
+ uploads = inject(UploadService);
3150
3165
  transloco = inject(TranslocoService);
3151
3166
  tabs = TABS;
3152
3167
  viewMode = signal("grid", ...(ngDevMode ? [{ debugName: "viewMode" }] : []));
@@ -3174,9 +3189,13 @@ class WorkspaceList {
3174
3189
  const changed = JSON.stringify(this.store.workspaces()) !== before;
3175
3190
  if (changed)
3176
3191
  this.selection.exit(); // the ticks belonged to the list that was there before
3192
+ // An upload someone else is running on a workspace listed here: only the status API tells.
3193
+ // It stops by itself after a tick when nothing is moving.
3194
+ this.uploads.startPolling();
3177
3195
  return changed;
3178
3196
  }, { intervalMs: 20_000, maxIntervalMs: 120_000 });
3179
3197
  constructor() {
3198
+ this.uploads.startPolling();
3180
3199
  void this.load();
3181
3200
  }
3182
3201
  /** Shows what is cached at once and refreshes behind it, or loads for the first time. */
@@ -3511,6 +3530,15 @@ function truncateName(name, max) {
3511
3530
  }
3512
3531
  return `${name.slice(0, max - 1)}…`;
3513
3532
  }
3533
+ /**
3534
+ * What to call a tracked row: the file's name — or, for a job started elsewhere whose files
3535
+ * the status API does not list, whose job it is.
3536
+ */
3537
+ function uploadLabel(upload, max) {
3538
+ if (upload.name)
3539
+ return { text: truncateName(upload.name, max) };
3540
+ return trackerMessage(upload.remote?.mine ? "ownUpload" : "remoteUpload");
3541
+ }
3514
3542
  /** `m:ss` for a duration in ms, clamped at 0 so a late tick cannot go negative. */
3515
3543
  function formatClock(ms) {
3516
3544
  const seconds = Math.max(0, Math.round(ms / 1000));
@@ -5792,10 +5820,10 @@ class WorkspaceDetail {
5792
5820
  /** The background refresh found the workspace gone from the server (deleted elsewhere). */
5793
5821
  deletedElsewhere = signal(false, ...(ngDevMode ? [{ debugName: "deletedElsewhere" }] : []));
5794
5822
  workspace = computed(() => this.store.workspace(this.workspaceId()), ...(ngDevMode ? [{ debugName: "workspace" }] : []));
5795
- // Only what is really uploaded or being indexed is listed: transfers and failures
5796
- // belong to the upload tracker, not to the workspace. A document with no status at
5797
- // all is one the server does not track — it is simply there, so it stays listed.
5798
- documents = computed(() => this.store.documentsOf(this.workspaceId()).filter(d => d.status !== "uploading" && d.status !== "error"), ...(ngDevMode ? [{ debugName: "documents" }] : []));
5823
+ // The list is the index: only a document the listing returns as indexed is shown. Transfers,
5824
+ // indexing and failures belong to the upload tracker, not to the workspace. A document with no
5825
+ // status at all is one the server does not track — it is simply there, so it stays listed.
5826
+ documents = computed(() => this.store.documentsOf(this.workspaceId()).filter(d => !d.status), ...(ngDevMode ? [{ debugName: "documents" }] : []));
5799
5827
  previewed = computed(() => this.documents().find(d => d.id === this.previewedDocumentId()) ?? null, ...(ngDevMode ? [{ debugName: "previewed" }] : []));
5800
5828
  /** The width the user dragged the preview to, if they did. */
5801
5829
  userPreviewWidth = signal(null, ...(ngDevMode ? [{ debugName: "userPreviewWidth" }] : []));
@@ -5848,8 +5876,9 @@ class WorkspaceDetail {
5848
5876
  return false; // the user moved to another workspace meanwhile
5849
5877
  const documentsChanged = JSON.stringify(this.store.documentsOf(id)) !== beforeDocuments;
5850
5878
  const workspaceChanged = JSON.stringify(this.store.workspace(id)) !== beforeWorkspace;
5851
- if (documentsChanged)
5852
- this.uploads.watch(id); // a document that just appeared may still be indexing for someone else
5879
+ // Every tick, changed or not: an upload someone else just started shows no document yet, and the
5880
+ // tracker only learns of it from the status API. It stops by itself after a tick when nothing is moving.
5881
+ this.uploads.watch(id);
5853
5882
  return documentsChanged || workspaceChanged;
5854
5883
  }, { intervalMs: 15_000, maxIntervalMs: 90_000 });
5855
5884
  constructor() {
@@ -7114,6 +7143,10 @@ class UploadTrackerSection {
7114
7143
  const uploads = this.uploads();
7115
7144
  return uploads.length ? Math.round(uploads.reduce((sum, u) => sum + u.percent, 0) / uploads.length) : 0;
7116
7145
  }, ...(ngDevMode ? [{ debugName: "average" }] : []));
7146
+ /** The translation key standing for a row with no file name: a job started elsewhere. Only read when `name` is empty. */
7147
+ label(upload) {
7148
+ return upload.remote?.mine ? "documentWorkspaces.tracker.ownUpload" : "documentWorkspaces.tracker.remoteUpload";
7149
+ }
7117
7150
  round(value) {
7118
7151
  return Math.round(value);
7119
7152
  }
@@ -7190,8 +7223,8 @@ class UploadTrackerSection {
7190
7223
  <span
7191
7224
  class="min-w-0 flex-1 truncate text-xs text-(--font-neutral-black)"
7192
7225
  [class.text-(--font-error-base)]="upload.status === 'error' || upload.status === 'rejected'"
7193
- [attr.title]="upload.name"
7194
- >{{ upload.name }}</span
7226
+ [attr.title]="upload.name || (label(upload) | transloco)"
7227
+ >{{ upload.name || (label(upload) | transloco) }}</span
7195
7228
  >
7196
7229
  @if (upload.message) {
7197
7230
  <span class="max-w-40 shrink truncate text-[11px] text-(--font-neutral-muted)" [attr.title]="upload.message">{{ upload.message }}</span>
@@ -7200,9 +7233,12 @@ class UploadTrackerSection {
7200
7233
  @switch (upload.status) {
7201
7234
  @case ("uploading") {
7202
7235
  <span class="shrink-0 text-xs text-(--font-neutral-muted) tabular-nums">{{ round(upload.percent) }}%</span>
7203
- <button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
7204
- <x-mark-icon />
7205
- </button>
7236
+ <!-- A transfer this client does not hold cannot be stopped from here, and nor can it be closed while it goes on. -->
7237
+ @if (!upload.remote) {
7238
+ <button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
7239
+ <x-mark-icon />
7240
+ </button>
7241
+ }
7206
7242
  }
7207
7243
  @case ("indexing") {
7208
7244
  <!-- No percentage here: the server only reports one per job, shown by the bar above. -->
@@ -7332,8 +7368,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
7332
7368
  <span
7333
7369
  class="min-w-0 flex-1 truncate text-xs text-(--font-neutral-black)"
7334
7370
  [class.text-(--font-error-base)]="upload.status === 'error' || upload.status === 'rejected'"
7335
- [attr.title]="upload.name"
7336
- >{{ upload.name }}</span
7371
+ [attr.title]="upload.name || (label(upload) | transloco)"
7372
+ >{{ upload.name || (label(upload) | transloco) }}</span
7337
7373
  >
7338
7374
  @if (upload.message) {
7339
7375
  <span class="max-w-40 shrink truncate text-[11px] text-(--font-neutral-muted)" [attr.title]="upload.message">{{ upload.message }}</span>
@@ -7342,9 +7378,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
7342
7378
  @switch (upload.status) {
7343
7379
  @case ("uploading") {
7344
7380
  <span class="shrink-0 text-xs text-(--font-neutral-muted) tabular-nums">{{ round(upload.percent) }}%</span>
7345
- <button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
7346
- <x-mark-icon />
7347
- </button>
7381
+ <!-- A transfer this client does not hold cannot be stopped from here, and nor can it be closed while it goes on. -->
7382
+ @if (!upload.remote) {
7383
+ <button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
7384
+ <x-mark-icon />
7385
+ </button>
7386
+ }
7348
7387
  }
7349
7388
  @case ("indexing") {
7350
7389
  <!-- No percentage here: the server only reports one per job, shown by the bar above. -->
@@ -7385,7 +7424,9 @@ const SECTION_ORDER = ["uploading", "indexing", "done", "error", "rejected"];
7385
7424
  const FLASH_MS = 3000;
7386
7425
  const PEEK_MS = 3500;
7387
7426
  const WORKSPACE_COMPLETE_MS = 3500;
7388
- const AUTO_DISMISS_MS = 3000;
7427
+ // How long a panel whose uploads are all over stays: long enough to read the outcome, even of an
7428
+ // upload someone else started and the user was not watching.
7429
+ const AUTO_DISMISS_MS = FINISHED_UPLOAD_VISIBLE_MS;
7389
7430
  const DISMISS_ANIMATION_MS = 320;
7390
7431
  const HOVER_LEAVE_MS = 250;
7391
7432
  // The text on the strip leaves, then the new one comes: half a transition each.
@@ -7472,7 +7513,7 @@ class UploadTracker {
7472
7513
  const failed = this.store.failed();
7473
7514
  const severalWorkspaces = this.store.workspaces().length > 1;
7474
7515
  // One file is named, several are counted.
7475
- const label = (uploads, key, max) => uploads.length === 1 ? { text: truncateName(uploads[0].name, max) } : trackerMessage(key, { count: uploads.length });
7516
+ const label = (uploads, key, max) => uploads.length === 1 ? uploadLabel(uploads[0], max) : trackerMessage(key, { count: uploads.length });
7476
7517
  const chips = [];
7477
7518
  if (groups.uploading.length)
7478
7519
  chips.push({ kind: "uploading", busy: true, message: trackerMessage("uploadingCount", { count: groups.uploading.length }) });
@@ -7492,11 +7533,11 @@ class UploadTracker {
7492
7533
  return peek.message;
7493
7534
  const failed = this.store.failed();
7494
7535
  if (failed.length)
7495
- return failed.length === 1 ? { text: truncateName(failed[0].name, 22) } : trackerMessage("errorCount", { count: failed.length });
7536
+ return failed.length === 1 ? uploadLabel(failed[0], 22) : trackerMessage("errorCount", { count: failed.length });
7496
7537
  const { uploading, indexing, done } = this.store.byStatus();
7497
7538
  const active = uploading.length + indexing.length;
7498
7539
  if (done.length && !active)
7499
- return done.length === 1 ? { text: truncateName(done[0].name, 22) } : trackerMessage("indexedCount", { count: done.length });
7540
+ return done.length === 1 ? uploadLabel(done[0], 22) : trackerMessage("indexedCount", { count: done.length });
7500
7541
  return trackerMessage("fileCount", { count: active });
7501
7542
  }, ...(ngDevMode ? [{ debugName: "pillMessage" }] : []));
7502
7543
  state = computed(() => (this.mode() !== "minimized" ? "normal" : this.hover() ? "minimized-hover" : "minimized"), ...(ngDevMode ? [{ debugName: "state" }] : []));
@@ -7526,10 +7567,12 @@ class UploadTracker {
7526
7567
  this.completeWorkspace(complete);
7527
7568
  });
7528
7569
  });
7529
- // Everything indexed: hold the success message for a moment, then go away.
7570
+ // Everything is over: hold the outcome for a while, then go away — unless a failure of the
7571
+ // user's own is there to be read, which stays until they close it. A new upload cancels
7572
+ // this (nothing is settled any more), and the delay starts afresh when it ends.
7530
7573
  effect(() => {
7531
- const allDone = this.store.allDone();
7532
- untracked(() => (allDone ? this.startAutoDismiss() : this.cancelAutoDismiss()));
7574
+ const leaves = this.store.settled() && !this.store.authorFailed();
7575
+ untracked(() => (leaves ? this.startAutoDismiss() : this.cancelAutoDismiss()));
7533
7576
  });
7534
7577
  // The strip of a minimized panel says what is going on; when that changes, the text is swapped, not cut.
7535
7578
  effect(() => {
@@ -7590,9 +7633,9 @@ class UploadTracker {
7590
7633
  }
7591
7634
  this.mode.update(mode => (mode === "expanded" ? "inline" : "expanded"));
7592
7635
  }
7593
- /** Minimizes while there is work going on; once everything is over, closes for good. */
7636
+ /** Minimizes while there is work going on; once everything is over (failures included), closes for good. */
7594
7637
  dismiss() {
7595
- if (this.store.allDone() || this.mode() === "minimized") {
7638
+ if (this.store.settled() || this.mode() === "minimized") {
7596
7639
  this.reset();
7597
7640
  }
7598
7641
  else {
@@ -7648,14 +7691,14 @@ class UploadTracker {
7648
7691
  const failed = finished.filter(isFailed);
7649
7692
  if (done.length) {
7650
7693
  const last = done[done.length - 1];
7651
- const message = { text: truncateName(last.name, this.mode() === "minimized" ? 22 : 30) };
7694
+ const message = uploadLabel(last, this.mode() === "minimized" ? 22 : 30);
7652
7695
  if (this.mode() === "minimized")
7653
7696
  this.notify(message, "success");
7654
7697
  else if (this.mode() === "inline" && !this.store.allDone())
7655
7698
  this.showFlash(message);
7656
7699
  }
7657
7700
  if (failed.length && this.mode() === "minimized") {
7658
- this.notify({ text: truncateName(failed[failed.length - 1].name, 22) }, "error");
7701
+ this.notify(uploadLabel(failed[failed.length - 1], 22), "error");
7659
7702
  }
7660
7703
  }
7661
7704
  /**
@@ -7710,8 +7753,9 @@ class UploadTracker {
7710
7753
  startAutoDismiss() {
7711
7754
  if (this.dismissTimer)
7712
7755
  return;
7713
- // The success message is in the header: make sure it can be seen.
7714
- this.mode.set("inline");
7756
+ // The success message is in the header: make sure it can be seen. Failures are in the sections: leave those open.
7757
+ if (this.store.allDone())
7758
+ this.mode.set("inline");
7715
7759
  this.dismissTimer = this.replaceTimer(null, AUTO_DISMISS_MS, () => {
7716
7760
  this.dismissTimer = null;
7717
7761
  this.dismissing.set(true);
@@ -7847,8 +7891,8 @@ class UploadTracker {
7847
7891
  variant="ghost"
7848
7892
  size="xs"
7849
7893
  [iconOnly]="true"
7850
- [attr.aria-label]="(mode() === 'minimized' || store.allDone() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7851
- [tooltip]="(mode() === 'minimized' || store.allDone() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7894
+ [attr.aria-label]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7895
+ [tooltip]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7852
7896
  (click)="dismiss(); $event.stopPropagation()">
7853
7897
  <x-mark-icon />
7854
7898
  </button>
@@ -8053,8 +8097,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
8053
8097
  variant="ghost"
8054
8098
  size="xs"
8055
8099
  [iconOnly]="true"
8056
- [attr.aria-label]="(mode() === 'minimized' || store.allDone() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
8057
- [tooltip]="(mode() === 'minimized' || store.allDone() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
8100
+ [attr.aria-label]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
8101
+ [tooltip]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
8058
8102
  (click)="dismiss(); $event.stopPropagation()">
8059
8103
  <x-mark-icon />
8060
8104
  </button>