|
2 | 2 | // |
3 | 3 | // The one piece of workspace text handling that BOTH realms need: the session |
4 | 4 | // code renders the name into the system prompt, and the TUI plugin renders it |
5 | | -// into a dialog header. Kept free of imports and module state on purpose — |
| 5 | +// into a dialog header. Kept free of imports and mutable module state on purpose — |
6 | 6 | // `precedence.ts`, where this lived, is server-side only (see its header), and |
7 | 7 | // a plugin importing it would load a second copy of that module's state into |
8 | 8 | // the plugin realm. |
|
15 | 15 | * what the model reads. */ |
16 | 16 | export const MAX_WORKSPACE_NAME_CHARS = 80 |
17 | 17 | export function inertWorkspaceName(name: string): string { |
18 | | - const cleaned = name |
19 | | - .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ") |
20 | | - .replace(/\s+/g, " ") |
21 | | - .trim() |
| 18 | + const cleaned = oneLine(name) |
22 | 19 | const points = Array.from(cleaned) |
23 | 20 | return points.length > MAX_WORKSPACE_NAME_CHARS ? points.slice(0, MAX_WORKSPACE_NAME_CHARS - 1).join("") + "…" : cleaned |
24 | 21 | } |
@@ -47,3 +44,91 @@ export function workspaceLabel(name: string, id: string | undefined, budget = MA |
47 | 44 | // so it is what survives. |
48 | 45 | return label.length > budget ? suffix.trim() : label |
49 | 46 | } |
| 47 | + |
| 48 | +/** A name on one line: control characters (C0, DEL and C1) and the Unicode line and |
| 49 | + * paragraph separators become spaces, runs of whitespace collapse, ends are trimmed. |
| 50 | + * The one normalisation both the rendered and the compared name start from. */ |
| 51 | +function oneLine(name: string): string { |
| 52 | + return name |
| 53 | + .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ") |
| 54 | + .replace(/\s+/g, " ") |
| 55 | + .trim() |
| 56 | +} |
| 57 | + |
| 58 | +/** A name as the link pickers compare it: on one line, and the sharp S spelled out, |
| 59 | + * which collation otherwise keeps apart from "ss". */ |
| 60 | +function comparableName(name: string): string { |
| 61 | + return oneLine(name).replace(/[ßẞ]/g, "ss") |
| 62 | +} |
| 63 | + |
| 64 | +/** The bidi controls (ALM, LRM/RLM, LRE..RLO, LRI..PDI): harmless in a link, but they |
| 65 | + * can visually reverse or reorder a displayed name. */ |
| 66 | +const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g |
| 67 | +export function stripBidiControls(text: string): string { |
| 68 | + return text.replace(BIDI_CONTROLS, "") |
| 69 | +} |
| 70 | + |
| 71 | +/** A workspace name as a dialog shows it: inert, and with no bidi controls. */ |
| 72 | +export function displayWorkspaceName(name: string): string { |
| 73 | + return stripBidiControls(inertWorkspaceName(name)) |
| 74 | +} |
| 75 | + |
| 76 | +/** One collator for every comparison, pinned to `en`: an unpinned one follows the host |
| 77 | + * locale, and under Turkish `I` no longer pairs with `i`, under Danish `aa` equals `å`. */ |
| 78 | +export const NAME_COLLATOR = new Intl.Collator("en", { sensitivity: "accent", usage: "search" }) |
| 79 | + |
| 80 | +export interface Namesakes<T> { |
| 81 | + /** Every listed workspace with the name a quick create would use, in list order. */ |
| 82 | + all: T[] |
| 83 | + /** The first of them the caller owns, or undefined. The only one a picker opens on: a plain |
| 84 | + * Enter on a namesake links to it and sends this machine's memory there, which must not |
| 85 | + * happen to a colleague's workspace by accident. */ |
| 86 | + own: T | undefined |
| 87 | +} |
| 88 | + |
| 89 | +/** The listed workspaces already named what a quick create would call this project. |
| 90 | + * |
| 91 | + * Names compare by Unicode collation, ignoring case but not accents: `Straße` matches |
| 92 | + * `STRASSE`, `ΟΔΟΣ` matches `οδος` (final sigma) and `finance` matches `FINANCE` (ligature), |
| 93 | + * all through collation, while `ı` and `i` or `café` and `cafe` stay different. |
| 94 | + * Characters collation ignores, such as zero-width and bidi controls, do not make a name |
| 95 | + * different. Ownership counts only when both the owner and the caller are known. */ |
| 96 | +export function findNamesakes<T extends { name: string; ownerId?: number }>( |
| 97 | + list: readonly T[], |
| 98 | + proposedName: string, |
| 99 | + userId: number | undefined, |
| 100 | +): Namesakes<T> { |
| 101 | + const target = comparableName(proposedName) |
| 102 | + if (!target) return { all: [], own: undefined } |
| 103 | + const all = list.filter((workspace) => NAME_COLLATOR.compare(comparableName(workspace.name), target) === 0) |
| 104 | + const own = userId === undefined ? undefined : all.find((workspace) => workspace.ownerId === userId) |
| 105 | + return { all, own } |
| 106 | +} |
| 107 | + |
| 108 | +/** The hint a picker shows on a namesake's row, or undefined for any other row. */ |
| 109 | +export function namesakeHint<T extends { name: string; ownerId?: number }>( |
| 110 | + workspace: T, |
| 111 | + namesakes: Namesakes<T>, |
| 112 | + userId: number | undefined, |
| 113 | +): string | undefined { |
| 114 | + if (!namesakes.all.includes(workspace)) return undefined |
| 115 | + const someoneElses = userId !== undefined && workspace.ownerId !== undefined && workspace.ownerId !== userId |
| 116 | + return someoneElses ? "same name, owned by someone else" : "same name as this project" |
| 117 | +} |
| 118 | + |
| 119 | +/** Where a link picker opens: the current link, else the caller's own namesake, else create. */ |
| 120 | +export function linkPickerOpensOn<T extends { id: number; name: string; ownerId?: number }>( |
| 121 | + currentId: number | undefined, |
| 122 | + namesakes: Namesakes<T>, |
| 123 | +): number | "create" { |
| 124 | + return currentId ?? namesakes.own?.id ?? "create" |
| 125 | +} |
| 126 | + |
| 127 | +/** Whether a choice in a link picker or the setup dialog must confirm first: both create |
| 128 | + * paths start from the project's name, so either one would make a second namesake. */ |
| 129 | +export function confirmsNamesake( |
| 130 | + choice: "create" | "browser" | "workspace", |
| 131 | + namesakes: Namesakes<unknown>, |
| 132 | +): boolean { |
| 133 | + return choice !== "workspace" && namesakes.all.length > 0 |
| 134 | +} |
0 commit comments