@anchrd/intel-ui 0.23.0 → 0.28.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.
@@ -15,6 +15,12 @@ export type MoveVerdict = "ok" | "self" | "descendant" | "same-place";
15
15
 
16
16
  // The parent a row is filed under. Nodes and Flows keep their own record (ADR-0004), so the
17
17
  // answer is read from whichever one this row is, never from a merged shape.
18
+ //
19
+ // ⚠️ This is the parent the RECORD names, which since #429 is not always the level the row is shown
20
+ // in: a shared document is filed inside the sharer's folder and reaches the root only through the
21
+ // share. Everything about a move — which level loses the row, which one keeps it, and whether the
22
+ // root is "same place" — is a question about the level on screen, so the level travels with the
23
+ // move instead of being read back out of the record here (#446).
18
24
  export function parentOf(entry: TreeEntry): string | null {
19
25
  return entry.type === "node" ? entry.node.parentId : entry.flow.parentId;
20
26
  }
@@ -25,6 +31,30 @@ export function treeLevelKey(parentId: string | null): readonly unknown[] {
25
31
  return ["tree", parentId];
26
32
  }
27
33
 
34
+ /**
35
+ * Which cached levels a move has to take the row out of.
36
+ *
37
+ * ⚠️ Normally one, and the caller names it. When it does not — the title line has no tree level to
38
+ * name — the answer is not a guess but the full set of places the row can be standing in, and that
39
+ * set has exactly two members.
40
+ *
41
+ * It is two rather than "all of them" because of how a level is read (`db.ts`): every level except
42
+ * the root is strictly `parent_id IS ?`, so there the level and the record always agree. Only the
43
+ * root also collects the top of every readable region — a shared row whose own record names the
44
+ * sharer's folder (#429). So a row is either in the level its record names, or in the root.
45
+ *
46
+ * Cleaning up one level too many costs a re-read of a list the reader is looking at anyway.
47
+ * Cleaning up one too few is #446: the row stays where it no longer is.
48
+ */
49
+ export function levelsToClear(
50
+ entry: TreeEntry,
51
+ level: string | null | undefined,
52
+ ): (string | null)[] {
53
+ if (level !== undefined) return [level];
54
+ const recorded = parentOf(entry);
55
+ return recorded === null ? [null] : [recorded, null];
56
+ }
57
+
28
58
  // The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
29
59
  // plus on the moved row would still file into the folder it just left.
30
60
  function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
@@ -45,13 +75,32 @@ function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
45
75
  */
46
76
  export function moveVerdict(input: {
47
77
  draggedId: string;
78
+ // ⚠️ Two answers to "where is this row", and since #429 they can differ — which is why both are
79
+ // asked (#446). `draggedParentId` is what the record says and what a move would overwrite;
80
+ // `draggedLevel` is the level the row is shown in.
81
+ //
82
+ // "Same place" has to be true for either, and for different reasons:
83
+ //
84
+ // - The record: dropping a row on the folder its own record already names writes the value that
85
+ // is already there. Nothing happens, and offering it says something will.
86
+ // - The level: a shared row reaches the root only through the share, and its record names the
87
+ // sharer's folder. Dropping it on the root looks like a no-op to the reader — but it would
88
+ // write `parentId = null` and lift the document OUT of the folder it was shared from. That is
89
+ // the opposite of nothing, done by somebody who was told nothing would happen.
90
+ //
91
+ // `draggedLevel` is optional because a surface without a tree has no level to name — the resource
92
+ // menu opens the same dialog from a title line. There the record is the only answer there is.
48
93
  draggedParentId: string | null;
94
+ draggedLevel?: string | null | undefined;
49
95
  targetId: string | null;
50
96
  targetAncestors: ReadonlySet<string>;
51
97
  }): MoveVerdict {
52
98
  if (input.targetId === input.draggedId) return "self";
53
99
  if (input.targetId !== null && input.targetAncestors.has(input.draggedId)) return "descendant";
54
100
  if (input.targetId === input.draggedParentId) return "same-place";
101
+ if (input.draggedLevel !== undefined && input.targetId === input.draggedLevel) {
102
+ return "same-place";
103
+ }
55
104
  return "ok";
56
105
  }
57
106
 
@@ -98,11 +147,14 @@ export function moveErrorKey(error: unknown): string {
98
147
  */
99
148
  export function MoveDialog({
100
149
  entry,
150
+ level,
101
151
  initial,
102
152
  close,
103
153
  submit,
104
154
  }: {
105
155
  entry: TreeEntry;
156
+ // `undefined` where no tree level was rendered — the verdict then rests on the record alone.
157
+ level: string | null | undefined;
106
158
  initial: MoveDestination | null;
107
159
  close(): void;
108
160
  submit(destination: MoveDestination): void;
@@ -114,17 +166,20 @@ export function MoveDialog({
114
166
  // being moved is never offered, so its subtree can never be entered in the first place.
115
167
  const [path, setPath] = useState<MoveDestination[]>([]);
116
168
  const here: MoveDestination = path.at(-1) ?? { id: null, title: i18n.t("tree.move.root") };
117
- const level = useQuery({
169
+ const children = useQuery({
118
170
  queryKey: ["tree", here.id],
119
171
  queryFn: async () => await data.listTreeChildren(here.id),
120
172
  enabled: destination === null,
121
173
  });
122
- const folders = (level.data ?? []).filter(
174
+ const folders = (children.data ?? []).filter(
123
175
  (child) => child.kind === "folder" && child.id !== entry.id,
124
176
  );
125
177
  const verdict = moveVerdict({
126
178
  draggedId: entry.id,
127
179
  draggedParentId: parentOf(entry),
180
+ // Handed down by the caller, because the dialog is opened from two places and only one of them
181
+ // has a tree level to name (#446).
182
+ draggedLevel: level,
128
183
  targetId: here.id,
129
184
  targetAncestors: new Set(path.map((step) => step.id).filter((id) => id !== null)),
130
185
  });
@@ -147,7 +202,7 @@ export function MoveDialog({
147
202
  <p className="min-w-0 flex-1 truncate text-sm font-medium">{here.title}</p>
148
203
  </div>
149
204
  <ul className="max-h-56 space-y-1 overflow-y-auto">
150
- {level.isPending ? (
205
+ {children.isPending ? (
151
206
  <li className="px-2 py-1.5 text-sm text-muted-foreground">
152
207
  {i18n.t("common.loading")}
153
208
  </li>
@@ -236,7 +291,17 @@ export function useTreeMove({
236
291
  // folder so the row is where the eye follows it. Nobody else has a tree to open.
237
292
  onMoved?: ((destination: MoveDestination) => void) | undefined;
238
293
  } = {}): {
239
- start(entry: TreeEntry, destination: MoveDestination | null): void;
294
+ // `level` is the one the row is standing in, and the caller says it or says `undefined` — the tree
295
+ // knows it from the level it rendered into, the title line has no tree at all. Guessing it here
296
+ // with `parentOf` is what filed a moved row into a cache level that does not exist (#446).
297
+ //
298
+ // ⚠️ `undefined` is not "root". It means the level is unknown, and it is handled by cleaning up
299
+ // BOTH levels such a row can stand in — see `levelsToClear`.
300
+ start(
301
+ entry: TreeEntry,
302
+ level: string | null | undefined,
303
+ destination: MoveDestination | null,
304
+ ): void;
240
305
  error: unknown;
241
306
  dialog: React.ReactNode;
242
307
  } {
@@ -244,6 +309,7 @@ export function useTreeMove({
244
309
  const queryClient = useQueryClient();
245
310
  const [moving, setMoving] = useState<{
246
311
  entry: TreeEntry;
312
+ level: string | null | undefined;
247
313
  initial: MoveDestination | null;
248
314
  } | null>(null);
249
315
 
@@ -253,6 +319,7 @@ export function useTreeMove({
253
319
  destination,
254
320
  }: {
255
321
  entry: TreeEntry;
322
+ level: string | null | undefined;
256
323
  destination: MoveDestination;
257
324
  }) => {
258
325
  // `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
@@ -273,20 +340,21 @@ export function useTreeMove({
273
340
  });
274
341
  }
275
342
  },
276
- onMutate: async ({ entry, destination }) => {
277
- const fromKey = treeLevelKey(parentOf(entry));
343
+ onMutate: async ({ entry, level, destination }) => {
344
+ const fromKeys = levelsToClear(entry, level).map(treeLevelKey);
278
345
  const toKey = treeLevelKey(destination.id);
279
- await Promise.all([
280
- queryClient.cancelQueries({ queryKey: fromKey }),
281
- queryClient.cancelQueries({ queryKey: toKey }),
282
- ]);
283
- const snapshot = [
284
- [fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
285
- [toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
286
- ] as const;
287
- queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
288
- current?.filter((row) => row.id !== entry.id),
346
+ await Promise.all(
347
+ [...fromKeys, toKey].map(async (queryKey) => await queryClient.cancelQueries({ queryKey })),
289
348
  );
349
+ const snapshot = [
350
+ ...fromKeys.map((key) => [key, queryClient.getQueryData<TreeEntry[]>(key)] as const),
351
+ [toKey, queryClient.getQueryData<TreeEntry[]>(toKey)] as const,
352
+ ];
353
+ for (const key of fromKeys) {
354
+ queryClient.setQueryData<TreeEntry[]>(key, (current) =>
355
+ current?.filter((row) => row.id !== entry.id),
356
+ );
357
+ }
290
358
  // A level nobody has opened stays unloaded: writing one here would show a folder's contents
291
359
  // that were never read.
292
360
  queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
@@ -300,9 +368,11 @@ export function useTreeMove({
300
368
  for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
301
369
  },
302
370
  onSuccess: (_result, { destination }) => onMoved?.(destination),
303
- onSettled: async (_result, _error, { entry, destination }) => {
371
+ onSettled: async (_result, _error, { entry, level, destination }) => {
304
372
  await Promise.all([
305
- queryClient.invalidateQueries({ queryKey: treeLevelKey(parentOf(entry)) }),
373
+ ...levelsToClear(entry, level).map(
374
+ async (from) => await queryClient.invalidateQueries({ queryKey: treeLevelKey(from) }),
375
+ ),
306
376
  queryClient.invalidateQueries({ queryKey: treeLevelKey(destination.id) }),
307
377
  queryClient.invalidateQueries({
308
378
  queryKey: [entry.type === "flow" ? "flows" : "node-graph"],
@@ -313,19 +383,20 @@ export function useTreeMove({
313
383
  });
314
384
 
315
385
  return {
316
- start(entry, destination) {
386
+ start(entry, level, destination) {
317
387
  move.reset();
318
- setMoving({ entry, initial: destination });
388
+ setMoving({ entry, level, initial: destination });
319
389
  },
320
390
  error: move.isError ? move.error : null,
321
391
  dialog: moving ? (
322
392
  <MoveDialog
323
393
  entry={moving.entry}
394
+ level={moving.level}
324
395
  initial={moving.initial}
325
396
  close={() => setMoving(null)}
326
397
  submit={(destination) => {
327
398
  setMoving(null);
328
- move.mutate({ entry: moving.entry, destination });
399
+ move.mutate({ entry: moving.entry, level: moving.level, destination });
329
400
  }}
330
401
  />
331
402
  ) : null,
@@ -64,7 +64,7 @@ export function ViewToggle({ views = ["editor", "graph"] }: { views?: readonly I
64
64
  search: view === "editor" ? rest : { ...rest, view },
65
65
  });
66
66
  }}
67
- className="inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
67
+ className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
68
68
  >
69
69
  <Icon aria-hidden="true" className="size-4" />
70
70
  </TooltipTrigger>
@@ -1,13 +1,14 @@
1
1
  import type { Flow } from "@anchrd/intel-contract/flow";
2
2
  import type { Node } from "@anchrd/intel-contract/node";
3
3
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
- import { ArchiveRestore, Trash2 } from "lucide-react";
4
+ import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
5
5
  import { useState } from "react";
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
6
7
  import { useI18n } from "@/i18n/i18n-context.tsx";
7
8
  import { kindIcons } from "@/kind-icon.ts";
8
9
  import { Modal } from "@/modal/modal.tsx";
9
10
  import { useIntelRouterContext } from "@/router/router-context.ts";
10
- import { useDateTime } from "@/time/time-context.tsx";
11
+ import { RelativeTime } from "@/time/relative-time.tsx";
11
12
 
12
13
  // One archived thing, whichever side of the tree it came from. The two records stay apart
13
14
  // everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
@@ -22,12 +23,12 @@ interface ArchivedEntry {
22
23
  // Both sides of the tree can be purged (#457) — each through its own door, because node and flow
23
24
  // stay separate everywhere else (ADR-0004).
24
25
  purge(): Promise<{ purged: true; title: string }>;
26
+ previewPurge?(): Promise<{ inboundLinks: number; totalItems: number }>;
25
27
  }
26
28
 
27
29
  export function Archive() {
28
30
  const { data } = useIntelRouterContext();
29
31
  const i18n = useI18n();
30
- const dateTime = useDateTime();
31
32
  const queryClient = useQueryClient();
32
33
 
33
34
  const archived = useQuery({
@@ -38,8 +39,10 @@ export function Archive() {
38
39
  data.listFlows({ archivedOnly: true }),
39
40
  ]);
40
41
  const entries: ArchivedEntry[] = [
41
- ...nodes.items.flatMap((node) =>
42
- node.archivedAt
42
+ ...nodes.items.flatMap((node) => {
43
+ // Stable across reloads: after D1 commits, only this key can reach an R2 cleanup receipt.
44
+ const purgeKey = `purge-${node.id}`;
45
+ return node.archivedAt
43
46
  ? [
44
47
  {
45
48
  id: node.id,
@@ -54,11 +57,12 @@ export function Archive() {
54
57
  archived: false,
55
58
  idempotencyKey: crypto.randomUUID(),
56
59
  }),
57
- purge: () => data.purgeNode({ nodeId: node.id }),
60
+ purge: () => data.purgeNode({ nodeId: node.id, idempotencyKey: purgeKey }),
61
+ previewPurge: () => data.previewNodePurge({ nodeId: node.id }),
58
62
  } satisfies ArchivedEntry,
59
63
  ]
60
- : [],
61
- ),
64
+ : [];
65
+ }),
62
66
  ...flows.items.flatMap((flow: Flow) =>
63
67
  flow.archivedAt
64
68
  ? [
@@ -108,6 +112,12 @@ export function Archive() {
108
112
  // that it does not come back. The state lives here because the row underneath disappears the
109
113
  // moment the purge goes through.
110
114
  const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
115
+ const preview = useQuery({
116
+ queryKey: ["purge-preview", confirming?.id],
117
+ queryFn: async () => await confirming?.previewPurge?.(),
118
+ enabled: Boolean(confirming?.previewPurge),
119
+ retry: false,
120
+ });
111
121
  const purge = useMutation({
112
122
  mutationFn: async (entry: ArchivedEntry) => await entry.purge(),
113
123
  onSuccess: async () => {
@@ -169,25 +179,48 @@ export function Archive() {
169
179
  <span className="sr-only">{i18n.t(`node.kind.${entry.kind}`)}</span>
170
180
  <span className="grid min-w-0 flex-1 leading-tight">
171
181
  <span className="truncate text-sm font-medium">{entry.title}</span>
172
- <span className="truncate text-xs text-muted-foreground">
173
- {i18n.t("archive.archivedAt", { when: dateTime.at(entry.archivedAt) })}
182
+ <span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
183
+ {i18n.t("archive.archived")}
184
+ <RelativeTime value={entry.archivedAt} />
174
185
  </span>
175
186
  </span>
176
- {/* ⚠️ Both icons stand PERMANENTLY, not only on hover a deliberate exception to the
177
- row convention (#457). There are two of them, both rare, and this list is the only
178
- place they occur: the wall of icons anchrd/gate#237 was written against does not
179
- arise here. The way back keeps its text; the purge is a bare icon, so that the two
180
- do not weigh the same. */}
181
- <button
182
- type="button"
183
- disabled={restore.isPending}
184
- onClick={() => restore.mutate(entry)}
185
- aria-label={i18n.t("archive.restore", { title: entry.title })}
186
- className="inline-flex h-8 shrink-0 items-center gap-2 rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
187
- >
188
- <ArchiveRestore aria-hidden="true" className="size-4" />
189
- {i18n.t("archive.restoreAction")}
190
- </button>
187
+ {/* Both actions stay permanently visible: the archive is the one place where getting
188
+ an item back must never depend on discovering a hover-only control. */}
189
+ <TooltipProvider delayDuration={300}>
190
+ <Tooltip>
191
+ {/* The wrapper keeps the tooltip available while the button is disabled. */}
192
+ <TooltipTrigger asChild>
193
+ <span className="inline-flex shrink-0">
194
+ <button
195
+ type="button"
196
+ disabled={restore.isPending}
197
+ onClick={() => restore.mutate(entry)}
198
+ aria-label={i18n.t(
199
+ restore.isPending && restore.variables?.id === entry.id
200
+ ? "archive.restoring"
201
+ : "archive.restore",
202
+ { title: entry.title },
203
+ )}
204
+ aria-busy={restore.isPending && restore.variables?.id === entry.id}
205
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
206
+ >
207
+ {restore.isPending && restore.variables?.id === entry.id ? (
208
+ <LoaderCircle aria-hidden="true" className="size-4 animate-spin" />
209
+ ) : (
210
+ <ArchiveRestore aria-hidden="true" className="size-4" />
211
+ )}
212
+ </button>
213
+ </span>
214
+ </TooltipTrigger>
215
+ <TooltipContent>
216
+ {i18n.t(
217
+ restore.isPending && restore.variables?.id === entry.id
218
+ ? "archive.restoringAction"
219
+ : "archive.restoreAction",
220
+ )}
221
+ </TooltipContent>
222
+ </Tooltip>
223
+ </TooltipProvider>
191
224
  <button
192
225
  type="button"
193
226
  disabled={purge.isPending}
@@ -206,6 +239,29 @@ export function Archive() {
206
239
  <p className="text-sm text-muted-foreground">
207
240
  {i18n.t("archive.purge.body", { title: confirming.title })}
208
241
  </p>
242
+ {confirming.previewPurge && preview.data ? (
243
+ <div className="mt-2 space-y-2 text-sm text-muted-foreground">
244
+ <p>{i18n.t("archive.purge.items", { count: preview.data.totalItems })}</p>
245
+ <p>
246
+ {preview.data.inboundLinks === 0
247
+ ? i18n.t("archive.purge.links.none")
248
+ : preview.data.inboundLinks === 1
249
+ ? i18n.t("archive.purge.links.one")
250
+ : i18n.t("archive.purge.links.many", { count: preview.data.inboundLinks })}
251
+ </p>
252
+ </div>
253
+ ) : null}
254
+ {confirming.previewPurge && preview.isPending ? (
255
+ <p className="mt-2 text-sm text-muted-foreground">
256
+ {i18n.t("archive.purge.previewLoading")}
257
+ </p>
258
+ ) : null}
259
+ {confirming.previewPurge && preview.isError ? (
260
+ <p role="alert" className="mt-2 text-sm text-destructive">
261
+ {i18n.t("archive.purge.previewFailed")}
262
+ </p>
263
+ ) : null}
264
+ <p className="mt-2 text-sm text-muted-foreground">{i18n.t("archive.purge.grants")}</p>
209
265
  <div className="mt-6 flex justify-end gap-2">
210
266
  <button
211
267
  type="button"
@@ -216,7 +272,10 @@ export function Archive() {
216
272
  </button>
217
273
  <button
218
274
  type="button"
219
- disabled={purge.isPending}
275
+ disabled={
276
+ purge.isPending ||
277
+ (Boolean(confirming.previewPurge) && (preview.isPending || preview.isError))
278
+ }
220
279
  onClick={() => purge.mutate(confirming)}
221
280
  className="inline-flex h-8 items-center gap-2 rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
222
281
  >
@@ -9,6 +9,7 @@ import {
9
9
  FlowPublishPreview,
10
10
  FlowRequirements,
11
11
  FlowValidation,
12
+ FlowVersionList,
12
13
  ListFlowsInput,
13
14
  PreviewFlowPublishInput,
14
15
  PublishFlowInput,
@@ -39,6 +40,8 @@ import {
39
40
  NodeTable,
40
41
  NodeVersionList,
41
42
  PurgeNodeInput,
43
+ PurgeNodePreview,
44
+ PurgeNodePreviewInput,
42
45
  PurgeNodeResult,
43
46
  ReindexResult,
44
47
  ResolveNodeLinksInput,
@@ -50,6 +53,7 @@ import {
50
53
  UpdateNodeInput,
51
54
  } from "@anchrd/intel-contract/node";
52
55
  import {
56
+ ResourceAccessList,
53
57
  ResourceGrantList,
54
58
  RevokeGrantInput,
55
59
  RevokeGrantResult,
@@ -334,8 +338,16 @@ export function createIntelDataProvider(
334
338
  const parsed = PurgeNodeInput.parse(input);
335
339
  return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}`, PurgeNodeResult, {
336
340
  method: "DELETE",
341
+ body: JSON.stringify(parsed),
337
342
  });
338
343
  },
344
+ async previewNodePurge(input) {
345
+ const parsed = PurgeNodePreviewInput.parse(input);
346
+ return await request(
347
+ `/nodes/${encodeURIComponent(parsed.nodeId)}/purge-preview`,
348
+ PurgeNodePreview,
349
+ );
350
+ },
339
351
  async purgeFlow(input) {
340
352
  const parsed = PurgeFlowInput.parse(input);
341
353
  return await request(`/flows/${encodeURIComponent(parsed.flowId)}`, PurgeFlowResult, {
@@ -351,6 +363,12 @@ export function createIntelDataProvider(
351
363
  async listGrants(resourceId) {
352
364
  return await request(`/nodes/${encodeURIComponent(resourceId)}/grants`, ResourceGrantList);
353
365
  },
366
+ async listEffectiveAccess(resourceId) {
367
+ return await request(
368
+ `/nodes/${encodeURIComponent(resourceId)}/effective-access`,
369
+ ResourceAccessList,
370
+ );
371
+ },
354
372
  async shareNode(input) {
355
373
  const parsed = ShareInput.parse(input);
356
374
  return await request(`/nodes/${encodeURIComponent(parsed.resourceId)}/grants`, ShareResult, {
@@ -424,6 +442,9 @@ export function createIntelDataProvider(
424
442
  body: JSON.stringify(parsed),
425
443
  });
426
444
  },
445
+ async listFlowVersions(flowId) {
446
+ return await request(`/flows/${encodeURIComponent(flowId)}/versions`, FlowVersionList);
447
+ },
427
448
  async previewFlowPublish(input) {
428
449
  const parsed = PreviewFlowPublishInput.parse(input);
429
450
  return await request(
@@ -9,6 +9,7 @@ import type {
9
9
  FlowPublishPreview,
10
10
  FlowRequirements,
11
11
  FlowValidation,
12
+ FlowVersionList,
12
13
  ListFlowsInput,
13
14
  PreviewFlowPublishInput,
14
15
  PublishFlowInput,
@@ -40,6 +41,7 @@ import type {
40
41
  NodeTable,
41
42
  NodeVersionList,
42
43
  PurgeNodeInput,
44
+ PurgeNodePreviewInput,
43
45
  PurgeNodeResult,
44
46
  ReindexResult,
45
47
  ResolveNodeLinksInput,
@@ -132,9 +134,15 @@ export interface IntelDataProvider {
132
134
  // ⚠️ The one way across this seam after which nothing is really left (#457). The title comes back
133
135
  // because nothing can look it up afterwards.
134
136
  purgeNode(input: PurgeNodeInput): Promise<PurgeNodeResult>;
137
+ previewNodePurge(
138
+ input: PurgeNodePreviewInput,
139
+ ): Promise<import("@anchrd/intel-contract/node").PurgeNodePreview>;
135
140
  purgeFlow(input: PurgeFlowInput): Promise<PurgeFlowResult>;
136
141
  searchNodes(input: SearchInput): Promise<SearchResult>;
137
142
  listGrants(resourceId: string): Promise<ResourceGrantList>;
143
+ listEffectiveAccess(
144
+ resourceId: string,
145
+ ): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
138
146
  // The grant, and what the grant does not cover: the documents the flows in this folder read that
139
147
  // the new principal still cannot. A warning, never a refusal (ADR-0004 §4).
140
148
  shareNode(input: ShareInput): Promise<ShareResult>;
@@ -159,6 +167,7 @@ export interface IntelDataProvider {
159
167
  // stops resolving as another flow's callee; its versions stay untouched.
160
168
  archiveFlow(input: ArchiveFlowInput): Promise<Flow>;
161
169
  saveFlow(input: SaveFlowVersionInput): Promise<FlowDocument>;
170
+ listFlowVersions(flowId: string): Promise<FlowVersionList>;
162
171
  // Which version each sub-flow call will take once published, and which of them publishing
163
172
  // freezes. Read before publishing, so the author agrees to the pins rather than discovering them.
164
173
  previewFlowPublish(input: PreviewFlowPublishInput): Promise<FlowPublishPreview>;
@@ -5,7 +5,8 @@ import { useState } from "react";
5
5
  import type { I18n } from "@/i18n/i18n.types.ts";
6
6
  import { useI18n } from "@/i18n/i18n-context.tsx";
7
7
  import { useIntelRouterContext } from "@/router/router-context.ts";
8
- import { useDateTime } from "@/time/time-context.tsx";
8
+ import { RelativeTime } from "@/time/relative-time.tsx";
9
+ import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
9
10
 
10
11
  // One page is what a person reads before deciding, not what a database can return. The server caps
11
12
  // it at fifty; twenty is what fits on a screen without scrolling past the answer.
@@ -63,7 +64,7 @@ export function FlowRuns({ flowId }: { flowId: string }) {
63
64
  {i18n.t("runs.onlyFailed")}
64
65
  </label>
65
66
  </div>
66
- <div className="min-h-0 flex-1 overflow-y-auto p-5">
67
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto p-5">
67
68
  {runs.isPending && (
68
69
  <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
69
70
  )}
@@ -109,14 +110,13 @@ export function FlowRuns({ flowId }: { flowId: string }) {
109
110
  {runs.isFetchingNextPage ? i18n.t("common.loading") : i18n.t("runs.more")}
110
111
  </button>
111
112
  )}
112
- </div>
113
+ </TitleRowScrollArea>
113
114
  </section>
114
115
  );
115
116
  }
116
117
 
117
118
  function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; toggle(): void }) {
118
119
  const i18n = useI18n();
119
- const dateTime = useDateTime();
120
120
  const failed = run.status === "failed";
121
121
  const Chevron = open ? ChevronDown : ChevronRight;
122
122
  return (
@@ -135,7 +135,7 @@ function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; tog
135
135
  >
136
136
  {i18n.t(`runs.status.${run.status}`)}
137
137
  </span>
138
- <span>{dateTime.at(run.startedAt)}</span>
138
+ <RelativeTime value={run.startedAt} />
139
139
  <span className="text-muted-foreground">
140
140
  {run.durationMs === null
141
141
  ? i18n.t("runs.stillRunning")
@@ -38,7 +38,7 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
38
38
  import { selectedFrom, viewFrom } from "@/router/selection-search.ts";
39
39
  import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
40
40
  import { useResolvedTheme } from "@/theme/theme-context.tsx";
41
- import { TitleRow } from "@/title-row/title-row.tsx";
41
+ import { TitleRow, TitleRowFrame, TitleRowScrollArea } from "@/title-row/title-row.tsx";
42
42
 
43
43
  type CanvasNode = ReactFlowNode<{ node: FlowNode }, "intel">;
44
44
  type CanvasEdge = Edge;
@@ -455,7 +455,7 @@ function FlowsEditor() {
455
455
  }
456
456
 
457
457
  return (
458
- <div className="flex h-full min-h-0 flex-col">
458
+ <TitleRowFrame className="flex h-full min-h-0 flex-col">
459
459
  {selectedFlowId && document.data ? (
460
460
  <FlowTitle
461
461
  flow={document.data.flow}
@@ -634,14 +634,18 @@ function FlowsEditor() {
634
634
  <Controls />
635
635
  </ReactFlow>
636
636
  </section>
637
- <aside className="flex w-80 shrink-0 flex-col overflow-y-auto border-l bg-card">
638
- <NodeInspector
639
- node={selectedNode}
640
- update={updateNode}
641
- tools={tools.data?.items ?? []}
642
- flows={(callable.data?.items ?? []).filter((entry) => entry.id !== selectedFlowId)}
643
- />
644
- <FlowNeeds flowId={selectedFlowId} />
637
+ <aside className="flex w-80 shrink-0 flex-col border-l bg-card">
638
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto">
639
+ <NodeInspector
640
+ node={selectedNode}
641
+ update={updateNode}
642
+ tools={tools.data?.items ?? []}
643
+ flows={(callable.data?.items ?? []).filter(
644
+ (entry) => entry.id !== selectedFlowId,
645
+ )}
646
+ />
647
+ <FlowNeeds flowId={selectedFlowId} />
648
+ </TitleRowScrollArea>
645
649
  </aside>
646
650
  </>
647
651
  )}
@@ -673,7 +677,7 @@ function FlowsEditor() {
673
677
  }}
674
678
  />
675
679
  ) : null}
676
- </div>
680
+ </TitleRowFrame>
677
681
  );
678
682
  }
679
683
 
@@ -729,7 +733,7 @@ function FlowTitle({
729
733
  applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
730
734
  that names this flow. A flow is the only level with runs, and therefore the only one with
731
735
  three views (#35). */}
732
- <ViewToggle views={["editor", "graph", "runs"]} />
736
+ <SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
733
737
  <TooltipProvider delayDuration={300}>
734
738
  <Tooltip>
735
739
  <TooltipTrigger asChild>
@@ -742,8 +746,8 @@ function FlowTitle({
742
746
  onClick={onPublish}
743
747
  disabled={!publishable}
744
748
  aria-label={publishLabel}
745
- className={`inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${
746
- publishable && unpublished ? "border-primary text-primary" : ""
749
+ className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${
750
+ publishable && unpublished ? "bg-primary/10 text-primary" : ""
747
751
  }`}
748
752
  >
749
753
  <Send aria-hidden="true" className="size-4" />
@@ -753,14 +757,7 @@ function FlowTitle({
753
757
  <TooltipContent>{publishLabel}</TooltipContent>
754
758
  </Tooltip>
755
759
  </TooltipProvider>
756
- {/* A draft that was never saved has no version, and the button says so rather than claiming
757
- one (#432) — the same distinction the publish button next to it already draws. */}
758
- <SaveButton
759
- dirty={dirty && canMutate}
760
- saving={saving}
761
- stored={flow.currentVersionId !== null}
762
- onSave={onSave}
763
- />
760
+ <ViewToggle views={["editor", "graph", "runs"]} />
764
761
  </TitleRow>
765
762
  );
766
763
  }