@gogitcms/design-system 0.16.0-next.4 → 0.16.0-next.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.
@@ -6,7 +6,7 @@ import { Text } from "./Text";
6
6
  import { Icon } from "./Icon";
7
7
  import { Button, IconButton } from "./Button";
8
8
  import { Input } from "./Input";
9
- import { Avatar, Badge, DiffStat, Divider, SectionLabel } from "./primitives";
9
+ import { Avatar, Badge, CheckBox, DiffStat, Divider, SectionLabel } from "./primitives";
10
10
  import { NavRow } from "./NavRow";
11
11
  import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle } from "./layout";
12
12
  import { Segment, type SegmentItem } from "./Segment";
@@ -23,6 +23,7 @@ import {
23
23
  PresenceField,
24
24
  PresenceAvatars,
25
25
  useCollabRegister,
26
+ writeCollabValue,
26
27
  useCollabMirror,
27
28
  useCollabSynced,
28
29
  useCollabValue,
@@ -114,6 +115,12 @@ export type FieldSlotArgs = {
114
115
  // CRDT and publish presence.
115
116
  collab?: CollabApi;
116
117
  path?: string;
118
+ // Bumped when the host replaces this field's value outright rather than the
119
+ // user editing it — restoring it from a past version. A controlled control
120
+ // needs nothing from this (it already re-renders with the new value), but the
121
+ // body editor does: in a live session it ignores `value` and mirrors the
122
+ // shared fragment, so "the value changed" is not a thing it can observe.
123
+ resetNonce?: number;
117
124
  };
118
125
  export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
119
126
 
@@ -767,6 +774,11 @@ type FieldControlProps = {
767
774
  // When this number changes, the field programmatically focuses itself (used to
768
775
  // jump to a peer's field from its presence avatar).
769
776
  focusSignal?: number;
777
+ // Bumped when this field's value was replaced by the host rather than typed —
778
+ // restored from a past version. Only the host-supplied body editor needs it
779
+ // (see FieldSlotArgs.resetNonce); every built-in control is controlled and
780
+ // re-renders from `value` on its own.
781
+ resetNonce?: number;
770
782
  };
771
783
 
772
784
  // The scalar type an array's `of` maps to when rendering item controls.
@@ -780,7 +792,7 @@ function ofToType(of?: string): string {
780
792
  }
781
793
 
782
794
  function FieldControl(props: FieldControlProps) {
783
- const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal } = props;
795
+ const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
784
796
  const t = useTheme();
785
797
  const label = hideLabel ? "" : field.label || field.name;
786
798
  // A text field participates in live collaboration when the document has a
@@ -793,7 +805,7 @@ function FieldControl(props: FieldControlProps) {
793
805
 
794
806
  // Host-supplied control (e.g. the markdown editor) takes precedence.
795
807
  if (renderField) {
796
- const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
808
+ const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce });
797
809
  if (custom != null && custom !== false) {
798
810
  return (
799
811
  <View style={{ gap: t.space(2) }}>
@@ -2638,6 +2650,11 @@ function EntryDetail({
2638
2650
  const canRename = !readOnly && !!onRename;
2639
2651
  const canMove = !readOnly && !!onMove && glob.hierarchical;
2640
2652
  const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
2653
+ // Per-field "your value was replaced" counters, bumped by a restore. Only the
2654
+ // host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
2655
+ // built-in control is controlled and needs no telling.
2656
+ const [resetNonces, setResetNonces] = useState<Record<string, number>>({});
2657
+
2641
2658
  // Whether this column is showing the version browser instead of the form.
2642
2659
  // Deliberately per-column and ephemeral: history is something you open, read
2643
2660
  // and close, so it lives and dies with the column rather than in the URL.
@@ -2832,6 +2849,112 @@ function EntryDetail({
2832
2849
  [commit],
2833
2850
  );
2834
2851
 
2852
+ // Publish a value map the form did NOT arrive at by typing — a restore from a
2853
+ // past version, or a discard back to the saved document.
2854
+ //
2855
+ // Both used to write local state and stop there, which is only half the job in
2856
+ // a live session. The shared document is the source of truth there: every
2857
+ // collab control prefers its binding to the value handed to it, so a replaced
2858
+ // value was displayed for one frame and then overwritten by the room's
2859
+ // unchanged one, and no peer ever saw it. Anything that replaces values
2860
+ // wholesale has to come through here.
2861
+ //
2862
+ // `names` are the fields whose values were replaced; unchanged ones are left
2863
+ // alone so a replacement never churns the room (or nudges a peer's cursor)
2864
+ // over a value that did not move.
2865
+ const publishReplacement = useCallback(
2866
+ (previous: Record<string, unknown>, next: Record<string, unknown>, names: string[]) => {
2867
+ const moved = names.filter((name) => !sameValue(previous[name], next[name]));
2868
+ if (moved.length === 0) return;
2869
+
2870
+ if (entry.collab) {
2871
+ for (const name of moved) {
2872
+ // A body is a ProseMirror fragment rather than a field binding, so it
2873
+ // is not written here — its editor is told instead, by the nonce below.
2874
+ if (editFields.find((f) => f.name === name)?.source === "body") continue;
2875
+ writeCollabValue(entry.collab, name, previous[name], next[name]);
2876
+ }
2877
+ }
2878
+
2879
+ // Tell the replaced fields their value came from outside. Keyed per field
2880
+ // rather than one counter for the document: the body editor acts on this,
2881
+ // and replacing some unrelated field must not make it rebuild a body a
2882
+ // peer may be mid-sentence in.
2883
+ setResetNonces((prev) => {
2884
+ const bumped = { ...prev };
2885
+ for (const name of moved) bumped[name] = (bumped[name] ?? 0) + 1;
2886
+ return bumped;
2887
+ });
2888
+ },
2889
+ // editFields is derived from entry, captured by id.
2890
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2891
+ [entry.id, entry.collab],
2892
+ );
2893
+
2894
+ // Values taken from a past version and put back into the form.
2895
+ //
2896
+ // It is an edit like any other: the restored fields land in `values`, the
2897
+ // document goes unsaved, and the author saves it (or discards it) themselves.
2898
+ // Nothing is written to git here — history is a place you read, and rewriting
2899
+ // the document from it should still pass through the same review a typed edit
2900
+ // does.
2901
+ const restoreValues = useCallback(
2902
+ (restored: { fields: Record<string, unknown>; body?: string | null }) => {
2903
+ const previous = valuesRef.current;
2904
+ const next = { ...previous, ...restored.fields };
2905
+ // The body is a field in the form like any other; which one it is comes
2906
+ // from the schema (`source: "body"`), the same mapping buildChange uses in
2907
+ // the other direction. A document whose model has no body field simply
2908
+ // has no body to restore.
2909
+ const bodyField = "body" in restored ? editFields.find((f) => f.source === "body") : undefined;
2910
+ if (bodyField) next[bodyField.name] = restored.body ?? "";
2911
+
2912
+ const replaced = Object.keys(restored.fields);
2913
+ if (bodyField) replaced.push(bodyField.name);
2914
+
2915
+ // Touched under the first restored name so a restore that leaves a
2916
+ // required field empty shows its error immediately, rather than looking
2917
+ // saveable until Save is pressed.
2918
+ //
2919
+ // Committed BEFORE publishing: a collab control observing the room echoes
2920
+ // what it sees back through onChange, and that echo builds its next map by
2921
+ // spreading the current one. Publishing first would have it spread the
2922
+ // pre-restore map.
2923
+ commit(next, replaced[0] ?? SYNTHETIC_BODY);
2924
+ publishReplacement(previous, next, replaced);
2925
+ // Drilled-into groups address the pre-restore shape; a restored group
2926
+ // object can have different children entirely.
2927
+ setGroupPath([]);
2928
+ },
2929
+ // editFields is derived from entry, which commit already captures by id.
2930
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2931
+ [commit, entry.id, publishReplacement],
2932
+ );
2933
+
2934
+ // Throw away everything unsaved and go back to the document as stored.
2935
+ //
2936
+ // In a live session that is a change to the ROOM, not just to this screen: the
2937
+ // unsaved work is the collective state every client is looking at, and a
2938
+ // discard that only reverted the local form would leave the document it just
2939
+ // claimed to restore sitting in the CRDT, ready to come straight back.
2940
+ const discardEdits = useCallback(() => {
2941
+ const previous = valuesRef.current;
2942
+ const restored = seed();
2943
+ valuesRef.current = restored;
2944
+ setValues(restored);
2945
+ setTouched({});
2946
+ // The preview is rendering the draft being thrown away, and nothing else
2947
+ // will tell it otherwise: it is fed from edits, and a discard is the one
2948
+ // change to a document that produces no edit. Without this the preview
2949
+ // keeps showing work the author just deleted, until they type again.
2950
+ emitDraft(buildChange(entry.id, editFields, restored), entry);
2951
+ discard();
2952
+ publishReplacement(previous, restored, editFields.map((f) => f.name));
2953
+ setGroupPath([]);
2954
+ // seed/editFields derive from entry, captured by id.
2955
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2956
+ }, [entry.id, discard, emitDraft, publishReplacement]);
2957
+
2835
2958
  // A field edit within the active group-drill frame: write the value at the
2836
2959
  // frame's nested path, then commit the whole map (buildChange serializes it).
2837
2960
  const changeInFrame = useCallback(
@@ -2895,19 +3018,7 @@ function EntryDetail({
2895
3018
  {saveHandler && dirty ? (
2896
3019
  <Pressable
2897
3020
  testID="discard-changes"
2898
- onPress={() => {
2899
- const restored = seed();
2900
- valuesRef.current = restored;
2901
- setValues(restored);
2902
- setTouched({});
2903
- // The preview is rendering the draft that is being thrown away,
2904
- // and nothing else will tell it otherwise: it is fed from edits,
2905
- // and a discard is the one change to a document that produces no
2906
- // edit. Without this the preview keeps showing work the author
2907
- // just deleted, until they type again.
2908
- emitDraft(buildChange(entry.id, editFields, restored), entry);
2909
- discard();
2910
- }}
3021
+ onPress={discardEdits}
2911
3022
  style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
2912
3023
  >
2913
3024
  <Text variant="monoSm" color="tertiary">Discard</Text>
@@ -2973,6 +3084,21 @@ function EntryDetail({
2973
3084
  documentPath={entry.path}
2974
3085
  history={history}
2975
3086
  onClose={() => setShowHistory(false)}
3087
+ // Restoring needs somewhere for the values to go: a document that is
3088
+ // read-only, or that this host never wired a save for, gets the
3089
+ // history with no selection boxes at all rather than a form it can
3090
+ // dirty and never save. (saveHandler is already undefined when
3091
+ // readOnly, so this covers both.)
3092
+ onRestore={
3093
+ !saveHandler
3094
+ ? undefined
3095
+ : (restored) => {
3096
+ restoreValues(restored);
3097
+ // Back to the form, which is where the restored values now
3098
+ // are and where the decision to keep them is made.
3099
+ setShowHistory(false);
3100
+ }
3101
+ }
2976
3102
  />
2977
3103
  ) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
2978
3104
  fresh initial values (and the scroll resets to top) when the selected
@@ -3015,6 +3141,7 @@ function EntryDetail({
3015
3141
  collab={entry.collab}
3016
3142
  path={[...groupPath, f.name].join(".")}
3017
3143
  focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
3144
+ resetNonce={resetNonces[f.name]}
3018
3145
  />
3019
3146
  ))}
3020
3147
  </ScrollView>
@@ -3094,27 +3221,6 @@ function useMultiSelect(resetKey: string) {
3094
3221
  return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
3095
3222
  }
3096
3223
 
3097
- // CheckBox is the row-selection control for multi-select.
3098
- function CheckBox({ value, onToggle }: { value: boolean; onToggle: () => void }) {
3099
- const t = useTheme();
3100
- return (
3101
- <Pressable
3102
- // Stop the press from bubbling to the row's open handler (the checkbox
3103
- // sits inside the row Pressable) so toggling never also opens the entry.
3104
- onPress={(e?: { stopPropagation?: () => void }) => { e?.stopPropagation?.(); onToggle(); }}
3105
- testID="entry-checkbox"
3106
- style={{
3107
- width: 18, height: 18, borderRadius: t.radius.sm, borderWidth: 1,
3108
- borderColor: value ? t.color.borderStrong : t.color.borderDefault,
3109
- backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
3110
- alignItems: "center", justifyContent: "center",
3111
- }}
3112
- >
3113
- {value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
3114
- </Pressable>
3115
- );
3116
- }
3117
-
3118
3224
  // SelectionBar appears above the entry list when rows are checked; it offers a
3119
3225
  // bulk move and/or delete plus a clear action (each shown only when wired).
3120
3226
  function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
@@ -3257,7 +3363,7 @@ function EntriesList({
3257
3363
  <View style={{ flexDirection: "row", alignItems: "center" }}>
3258
3364
  {showChecks ? (
3259
3365
  <View style={{ paddingLeft: t.space(4) }}>
3260
- <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
3366
+ <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
3261
3367
  </View>
3262
3368
  ) : null}
3263
3369
  <View style={{ flex: 1 }}>
@@ -4,7 +4,8 @@ import { useTheme } from "../ThemeProvider";
4
4
  import { Text } from "./Text";
5
5
  import { Icon } from "./Icon";
6
6
  import { Badge } from "./primitives";
7
- import { ChangeDetail, type DocumentChange } from "./ChangeDetail";
7
+ import { Button } from "./Button";
8
+ import { BODY_FIELD, ChangeDetail, type DocumentChange } from "./ChangeDetail";
8
9
  import { firstLine, relativeTime, type DocumentVersion, type HistoryApi } from "../history";
9
10
 
10
11
  /** How many versions a page holds. One page is one provider request. */
@@ -18,6 +19,22 @@ export type DocumentHistoryProps = {
18
19
  history: HistoryApi;
19
20
  /** Leaves history and returns the column to the editor. */
20
21
  onClose: () => void;
22
+ /**
23
+ * Put a past version's values for the chosen fields back into the editor.
24
+ *
25
+ * It restores into the FORM, not into git and not straight to a save: the
26
+ * document becomes unsaved with those fields rolled back, and the author
27
+ * reviews and saves it like any other edit. Reading history and rewriting the
28
+ * document are two different acts, and only one of them should be one click
29
+ * from a list you are still browsing.
30
+ *
31
+ * `body` is present only when the body was among the chosen fields — null is
32
+ * a real restorable value (a document that had no body), so its absence has
33
+ * to be expressed by the key being missing rather than by null.
34
+ *
35
+ * Omitted (a read-only document) → no boxes and no restore bar, only reading.
36
+ */
37
+ onRestore?: (restored: { fields: Record<string, unknown>; body?: string | null }) => void;
21
38
  };
22
39
 
23
40
  /**
@@ -30,7 +47,7 @@ export type DocumentHistoryProps = {
30
47
  * and close, not a place the editor navigates to. Nothing here survives closing
31
48
  * the pane, and nothing about it is in the URL.
32
49
  */
33
- export function DocumentHistory({ documentId, documentPath, history, onClose }: DocumentHistoryProps) {
50
+ export function DocumentHistory({ documentId, documentPath, history, onClose, onRestore }: DocumentHistoryProps) {
34
51
  const t = useTheme();
35
52
 
36
53
  const [versions, setVersions] = useState<DocumentVersion[]>([]);
@@ -43,6 +60,14 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
43
60
  const [detail, setDetail] = useState<DocumentChange | undefined>(undefined);
44
61
  const [loadingDetail, setLoadingDetail] = useState(false);
45
62
  const [detailError, setDetailError] = useState<string | null>(null);
63
+ // Which fields the reader has picked to take from this version. Cleared on
64
+ // every version change below: a tick means "restore THIS value", so carrying
65
+ // it to another version would silently repoint it at a different one.
66
+ const [picked, setPicked] = useState<readonly string[]>([]);
67
+ const togglePick = useCallback(
68
+ (name: string) => setPicked((p) => (p.includes(name) ? p.filter((n) => n !== name) : [...p, name])),
69
+ [],
70
+ );
46
71
 
47
72
  // Guards every async result against a document switch or an unmount: the pane
48
73
  // is inside a column whose document can change under it, and a page that
@@ -66,6 +91,7 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
66
91
  setExhausted(false);
67
92
  setSelected(null);
68
93
  setDetail(undefined);
94
+ setPicked([]);
69
95
  history
70
96
  .list({ documentId, limit: PAGE, offset: 0 })
71
97
  .then((page) => {
@@ -110,6 +136,10 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
110
136
 
111
137
  // The selected version's document + diff.
112
138
  useEffect(() => {
139
+ // A tick names a value, not a field: it means "take THIS version's title".
140
+ // Carrying the selection to the next version would keep the ticks while
141
+ // silently repointing them at different values.
142
+ setPicked([]);
113
143
  if (!selected) {
114
144
  setDetail(undefined);
115
145
  return;
@@ -137,6 +167,29 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
137
167
  };
138
168
  }, [documentId, history, selected]);
139
169
 
170
+ // Restore is offered only for a document the caller can write AND a version
171
+ // that differs from it: a version identical to the current document has no
172
+ // field whose old value is anything but the value already there.
173
+ const canRestore = !!onRestore && !!detail && detail.status !== "U";
174
+
175
+ const restore = useCallback(() => {
176
+ if (!onRestore || !detail) return;
177
+ const chosen = new Set(picked);
178
+ const fields: Record<string, unknown> = {};
179
+ for (const f of detail.fields) {
180
+ if (!chosen.has(f.name)) continue;
181
+ // `before` is this version's value, which is the whole point — and it is
182
+ // undefined for a field the version did not have. Restoring that has to
183
+ // mean "clear it", so it is written as undefined rather than skipped.
184
+ fields[f.name] = f.before;
185
+ }
186
+ const restored: { fields: Record<string, unknown>; body?: string | null } = { fields };
187
+ // The key is present only when the body was picked: null is a real value to
188
+ // restore (a document that had no body), so absence cannot be spelled null.
189
+ if (chosen.has(BODY_FIELD)) restored.body = detail.bodyBefore ?? null;
190
+ onRestore(restored);
191
+ }, [onRestore, detail, picked]);
192
+
140
193
  return (
141
194
  <View testID="document-history" style={{ flex: 1, minHeight: 0, flexDirection: "row" }}>
142
195
  {/* Left: the document at the selected version, annotated with what has
@@ -153,8 +206,11 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
153
206
  change={detail}
154
207
  loading={loadingDetail}
155
208
  emptyMessage={loading ? "Loading history…" : "Select a version"}
209
+ selectedFields={picked}
210
+ onToggleField={canRestore ? togglePick : undefined}
156
211
  />
157
212
  )}
213
+ {canRestore ? <RestoreBar count={picked.length} onRestore={restore} onClear={() => setPicked([])} /> : null}
158
214
  </View>
159
215
 
160
216
  {/* Right: the versions. */}
@@ -237,6 +293,66 @@ export function DocumentHistory({ documentId, documentPath, history, onClose }:
237
293
  );
238
294
  }
239
295
 
296
+ /**
297
+ * The restore action, docked under the diff.
298
+ *
299
+ * Always mounted while a writable version is selected, rather than appearing
300
+ * once something is ticked: a bar that materialises on the first tick shifts
301
+ * the diff under the hand that just ticked it, and the disabled button is what
302
+ * tells you the boxes are for something in the first place.
303
+ */
304
+ function RestoreBar({
305
+ count,
306
+ onRestore,
307
+ onClear,
308
+ }: {
309
+ count: number;
310
+ onRestore: () => void;
311
+ onClear: () => void;
312
+ }) {
313
+ const t = useTheme();
314
+ return (
315
+ <View
316
+ testID="restore-bar"
317
+ style={{
318
+ flexDirection: "row",
319
+ alignItems: "center",
320
+ gap: t.space(2),
321
+ paddingHorizontal: t.space(4),
322
+ paddingVertical: t.space(2),
323
+ borderTopWidth: 1,
324
+ borderTopColor: t.color.borderSubtle,
325
+ backgroundColor: t.color.surfaceSunken,
326
+ }}
327
+ >
328
+ <Text variant="monoSm" color="tertiary" style={{ flex: 1 }}>
329
+ {count === 0
330
+ ? "Tick a field to take its earlier value"
331
+ : count === 1
332
+ ? "1 field selected"
333
+ : `${count} fields selected`}
334
+ </Text>
335
+ {count > 0 ? (
336
+ <Pressable onPress={onClear} testID="restore-clear" style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}>
337
+ <Text variant="monoSm" color="tertiary">Clear</Text>
338
+ </Pressable>
339
+ ) : null}
340
+ <Button
341
+ testID="restore-apply"
342
+ // Named for what it does to the thing in front of you. It does not write
343
+ // to git and does not save — it puts the values back in the form, and
344
+ // the author still has to save them.
345
+ title={count > 1 ? `Restore ${count} fields` : "Restore field"}
346
+ size="sm"
347
+ variant={count > 0 ? "primary" : "default"}
348
+ disabled={count === 0}
349
+ onPress={onRestore}
350
+ style={{ alignSelf: "center" }}
351
+ />
352
+ </View>
353
+ );
354
+ }
355
+
240
356
  function VersionRow({
241
357
  version,
242
358
  selected,
@@ -4,10 +4,11 @@ import { useTheme, useThemeMode, ThemeScope } from "../ThemeProvider";
4
4
  import { lightTheme, darkTheme } from "../theme";
5
5
  import { Text } from "./Text";
6
6
  import { Icon } from "./Icon";
7
+ import type { IconName } from "../icons";
7
8
  import { Spinner } from "./Spinner";
8
9
 
9
10
  // One notification, shaped for display. Transport-agnostic: the frontend/mobile
10
- // apps map GraphQL rows and the desktop maps REST rows into this.
11
+ // apps map GraphQL rows and the desktop and dashboard map REST rows into this.
11
12
  export type NotificationItem = {
12
13
  id: string;
13
14
  title: string;
@@ -16,8 +17,39 @@ export type NotificationItem = {
16
17
  read: boolean;
17
18
  createdAt: string; // ISO 8601
18
19
  author?: string; // display name/email of who caused it, when known
20
+ // What produced it — picks the icon. Unknown kinds fall back to the bell.
21
+ kind?: NotificationKind | string;
22
+ // Where it happened, for a list that spans several repositories/projects.
23
+ // Omit whichever the list is already scoped to.
24
+ repository?: string;
25
+ project?: string;
19
26
  };
20
27
 
28
+ export type NotificationKind = "import" | "export" | "merge" | "change_request" | "member" | "branch" | "project";
29
+
30
+ // The icon for a notification kind. Exported so hosts that draw their own rows
31
+ // (a scope picker's section headers, say) agree with the list.
32
+ export function notificationKindIcon(kind?: string): IconName {
33
+ switch (kind) {
34
+ case "import":
35
+ return "download";
36
+ case "export":
37
+ return "upload";
38
+ case "merge":
39
+ return "gitMerge";
40
+ case "change_request":
41
+ return "gitPullRequest";
42
+ case "member":
43
+ return "user";
44
+ case "branch":
45
+ return "gitBranch";
46
+ case "project":
47
+ return "folder";
48
+ default:
49
+ return "bell";
50
+ }
51
+ }
52
+
21
53
  // relativeTime renders a compact age ("just now", "5m", "3h", "2d").
22
54
  function relativeTime(iso: string): string {
23
55
  const then = Date.parse(iso);
@@ -32,7 +64,65 @@ function relativeTime(iso: string): string {
32
64
  return `${days}d`;
33
65
  }
34
66
 
35
- // A single notification row: an unread dot + level-tinted title, body, and age.
67
+ // InlineCode renders a sentence whose backticked spans (`main`, `#12`'s
68
+ // branch) are set in the mono face, so a title like "Created `zyx` branch"
69
+ // reads as prose around a name rather than as punctuation. An unmatched
70
+ // backtick is left as typed.
71
+ export function InlineCode({
72
+ text,
73
+ variant = "sm",
74
+ weight,
75
+ color,
76
+ numberOfLines,
77
+ testID,
78
+ }: {
79
+ text: string;
80
+ variant?: "sm" | "body" | "monoSm" | "xs";
81
+ weight?: "regular" | "medium" | "semibold";
82
+ color?: string;
83
+ numberOfLines?: number;
84
+ testID?: string;
85
+ }) {
86
+ const t = useTheme();
87
+ const parts = text.split("`");
88
+ // An even number of parts means an odd number of backticks: not a pair, so
89
+ // render the text untouched.
90
+ if (parts.length % 2 === 0) {
91
+ return (
92
+ <Text variant={variant} weight={weight} color={color} numberOfLines={numberOfLines} testID={testID}>
93
+ {text}
94
+ </Text>
95
+ );
96
+ }
97
+ return (
98
+ <Text variant={variant} weight={weight} color={color} numberOfLines={numberOfLines} testID={testID}>
99
+ {parts.map((part, i) =>
100
+ i % 2 === 1 ? (
101
+ <Text
102
+ key={i}
103
+ variant={variant}
104
+ weight={weight}
105
+ color={color}
106
+ mono
107
+ style={{
108
+ backgroundColor: t.color.surfaceSunken,
109
+ borderRadius: t.radius.sm,
110
+ paddingHorizontal: 3,
111
+ }}
112
+ >
113
+ {part}
114
+ </Text>
115
+ ) : (
116
+ part
117
+ ),
118
+ )}
119
+ </Text>
120
+ );
121
+ }
122
+
123
+ // A single notification row: a kind icon, the title (unread rows in bold, error
124
+ // rows tinted), the body, and a context line — where it happened and who did
125
+ // it — with the age on the right.
36
126
  function NotificationRow({
37
127
  item,
38
128
  divided,
@@ -44,12 +134,16 @@ function NotificationRow({
44
134
  }) {
45
135
  const t = useTheme();
46
136
  const titleColor = item.level === "error" ? t.color.diffDelFg : t.color.textPrimary;
137
+ const context = [item.repository, item.project].filter(Boolean).join(" · ");
138
+ const meta = [context, item.author ? `by ${item.author}` : ""].filter(Boolean).join(" · ");
47
139
  return (
48
140
  <Pressable
49
141
  onPress={onPress}
142
+ disabled={!onPress}
143
+ testID={`notification-${item.id}`}
50
144
  style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
51
145
  flexDirection: "row",
52
- gap: t.space(2.5),
146
+ gap: t.space(3),
53
147
  paddingHorizontal: t.space(4),
54
148
  paddingVertical: t.space(3),
55
149
  borderBottomWidth: divided ? 1 : 0,
@@ -57,33 +151,57 @@ function NotificationRow({
57
151
  backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
58
152
  })}
59
153
  >
60
- <View
61
- style={{
62
- width: 8,
63
- height: 8,
64
- marginTop: 5,
65
- borderRadius: t.radius.pill,
66
- backgroundColor: item.read ? "transparent" : t.color.diffDelFg,
67
- }}
68
- />
69
- <View style={{ flex: 1, gap: 2 }}>
70
- <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
71
- <Text
72
- variant="sm"
73
- weight={item.read ? "medium" : "semibold"}
74
- color={titleColor}
75
- numberOfLines={2}
76
- style={{ flex: 1 }}
77
- >
78
- {item.title}
79
- </Text>
154
+ <View style={{ width: 28, height: 28, alignItems: "center", justifyContent: "center", position: "relative" }}>
155
+ <View
156
+ style={{
157
+ width: 28,
158
+ height: 28,
159
+ borderRadius: t.radius.pill,
160
+ alignItems: "center",
161
+ justifyContent: "center",
162
+ backgroundColor: item.level === "error" ? t.color.diffDelBg : t.color.surfaceSunken,
163
+ }}
164
+ >
165
+ <Icon
166
+ name={notificationKindIcon(item.kind)}
167
+ size={14}
168
+ color={item.level === "error" ? t.color.diffDelFg : t.color.textSecondary}
169
+ />
170
+ </View>
171
+ {/* Unread dot, riding on the icon's corner. */}
172
+ {item.read ? null : (
173
+ <View
174
+ testID="notification-unread"
175
+ style={{
176
+ position: "absolute",
177
+ top: -1,
178
+ right: -1,
179
+ width: 8,
180
+ height: 8,
181
+ borderRadius: t.radius.pill,
182
+ backgroundColor: t.color.diffDelFg,
183
+ borderWidth: 1,
184
+ borderColor: t.color.surfaceRaised,
185
+ }}
186
+ />
187
+ )}
188
+ </View>
189
+ <View style={{ flex: 1, gap: 2, minWidth: 0 }}>
190
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: t.space(2) }}>
191
+ <View style={{ flex: 1, minWidth: 0 }}>
192
+ <InlineCode
193
+ text={item.title}
194
+ variant="sm"
195
+ weight={item.read ? "medium" : "semibold"}
196
+ color={titleColor}
197
+ numberOfLines={2}
198
+ />
199
+ </View>
80
200
  <Text variant="monoSm" color="tertiary">{relativeTime(item.createdAt)}</Text>
81
201
  </View>
82
- {item.body ? (
83
- <Text variant="monoSm" color="secondary" numberOfLines={2}>{item.body}</Text>
84
- ) : null}
85
- {item.author ? (
86
- <Text variant="monoSm" color="tertiary" numberOfLines={1}>{`by ${item.author}`}</Text>
202
+ {item.body ? <InlineCode text={item.body} variant="monoSm" color={t.color.textSecondary} numberOfLines={2} /> : null}
203
+ {meta ? (
204
+ <Text variant="monoSm" color="tertiary" numberOfLines={1}>{meta}</Text>
87
205
  ) : null}
88
206
  </View>
89
207
  </Pressable>