From fcb906add84466a235e37a256a9d711859da91f5 Mon Sep 17 00:00:00 2001 From: "claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla)" Date: Sat, 8 Aug 2026 23:13:24 +0000 Subject: [PATCH] Link records from inside a body, by autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `[[` in any markdown field with a space in scope offers that space's goals, artifacts, thread messages and projects, and picking one writes `[](at://did/collection/rkey)` — an ordinary markdown link naming an ordinary record. Read back, that link goes to the record's page in the app; a URI this tab does not hold keeps the inert chip it has always drawn. Nothing about the protocol moves: no lexicon, no materializer, no daemon, and the body written is byte-identical markdown an older client already renders. The URI rather than an app URL is the `radial-image:` precedent (design §4) — it survives its author moving PDS and this app moving origin, and an agent reading the body gets something it can act on. - `src/lib/links.ts`: the candidates, the exact string a pick writes, the completion rule as a pure function over an interface `CompletionContext` happens to satisfy (so the rule holds without CodeMirror and without a DOM), and the read-time reverse lookup. Resolution is closed — exact string equality against the local index, never a fetch — so a hostile body can cause a dictionary miss and nothing else. - `editor.ts`: `@codemirror/autocomplete`, imported inside the same dynamic `Promise.all` as the rest of the library, themed in the app's own tokens and parented to the body (`.md-host` is `overflow: hidden`). Escape is gated on `completionStatus` so one press closes the list and the next leaves the field. - Wired on every composing surface that has a space: goal bodies, messages and replies, request briefs, both ask cards, review findings, the guest composer. Off for an artifact type's template, on the images precedent. The textarea half offers nothing, the same degradation contract highlighting has. - Filtering reuses each row's own quick-find projection, which is why `goalSearchText` now sits beside `labelCorpus` and `/goals` reads it rather than spelling the corpus out. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- DESIGN.md | 1 + packages/ui/README.md | 31 ++ packages/ui/package.json | 1 + .../ui/src/lib/components/AskAnswer.svelte | 2 + .../ui/src/lib/components/AskReview.svelte | 2 + .../ui/src/lib/components/Composer.svelte | 2 + .../src/lib/components/GuestComposer.svelte | 2 + .../src/lib/components/MarkdownEditor.svelte | 20 +- .../components/MarkdownEditor.svelte.test.ts | 113 ++++++ packages/ui/src/lib/components/NewGoal.svelte | 2 + .../ui/src/lib/components/NewRequest.svelte | 2 + packages/ui/src/lib/components/Runs.svelte | 15 +- .../ui/src/lib/components/UnitDetail.svelte | 2 + packages/ui/src/lib/components/Verdict.svelte | 2 + packages/ui/src/lib/editor.ts | 87 ++++- packages/ui/src/lib/filters.test.ts | 8 + packages/ui/src/lib/filters.ts | 12 + packages/ui/src/lib/links.test.ts | 274 +++++++++++++++ packages/ui/src/lib/links.ts | 330 ++++++++++++++++++ packages/ui/src/routes/goals/+page.svelte | 8 +- packages/ui/test/record-links.test.mjs | 94 +++++ pnpm-lock.yaml | 3 + 22 files changed, 1006 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/lib/links.test.ts create mode 100644 packages/ui/src/lib/links.ts create mode 100644 packages/ui/test/record-links.test.mjs diff --git a/DESIGN.md b/DESIGN.md index 289c072..ab2cd32 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -404,6 +404,7 @@ fill *is* the state (a pressed version button, an "on" auto-review switch). ### The markdown editor - **It is a field, not a code editor.** Every box holding markdown — a goal's body, a message, a brief, a type's template, a finding — carries the same border, radius, type and focus ring as the inputs above. What syntax highlighting adds is *confirmation* that what you typed is a heading or a link; drawing it as an editor would make a goal's body look like a different kind of thing from its title, and the fields that are not markdown (titles, paths, line numbers, one-line descriptions) stay ordinary controls for the same reason. - **The palette is the app's.** Headings and emphasis take weight and `ink` rather than a colour of their own; links take the accent; code takes the mono face and `ink-2`; the punctuation that makes something a heading recedes to `stroke` but is never hidden — markdown is text, and an editor that hides its own syntax has started lying about what the document says. +- **Naming another record** is a keystroke, not a control: type `[[` and the space's goals, artifacts, messages and projects are offered in a list drawn in the app's own tokens — the row at the field's size, where it hangs off a step down in `ink-2`, the selected row filled with `accent-wash` like every other selection. No icon column: the detail already says what a record is, and a second glyph vocabulary beside the state circles and badges would be one more thing to learn. Escape closes the list, and a second Escape leaves the field — one layer per press, the same rule the keyboard map and the drawer keep. In prose, the link it wrote is an ordinary in-app link; a URI this space does not hold stays the inert mono chip it has always been, because a link that cannot go anywhere must not look like one that can. - **Adding an image** is a link, not a button: `Add image` sits under the box with the formats beside it, because it is one more thing you can do with what you are typing rather than an action on the record. The buttons that write the record are already below it, and one of them says `Adding image…` and refuses while a file is in flight. The review card is the one surface with several markdown boxes and one record, so a picture in any finding puts that copy on *both* verdict buttons, and the row it is landing in cannot be removed until it has. ### Navigation (the rail) diff --git a/packages/ui/README.md b/packages/ui/README.md index 151aefc..39bb173 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -54,6 +54,7 @@ if a `node:*` import creeps back onto that path. | `src/lib/directory.ts` | Membership → the row an actor gets: kind, initials, disc color. Color is derived from the DID, never authored. An agent's `artifactTypes` here is the union of its profiles' **effective** lists for the space the directory was built over (`agentTypesFor`, design §11), so every picker filtering on it is already space-scoped. | | `src/lib/prose.ts` | The comp's small `## ` + backtick markup grammar. Parses to a structure the component interpolates — no `{@html}` anywhere near an agent-authored body. | | `src/lib/editor.ts` | CodeMirror, assembled and loaded on demand. The only module that imports it, and only through `import()` — `test/browser-bundle.test.mjs` fails if a static one appears anywhere under `src`. | +| `src/lib/links.ts` | Naming another record from inside a body, both directions: the candidates a `[[` offers (goals, artifacts, messages, projects — each carrying the quick-find projection its own row is found by), the exact `[title](at://…)` a pick writes, the whole completion rule as a pure function over an interface `CompletionContext` happens to satisfy, and the read-time reverse lookup that turns an `at://` href into a page. Resolution is closed — exact string equality against the local index, never a fetch — so an unknown URI is a dictionary miss and keeps today's chip. | | `src/lib/insertion.ts` | Where an uploaded image goes: carrying an insertion point across what the reader typed while the blob was on the wire, and refusing the one point that would nest two images. Pure, and held without a DOM. | | `src/lib/images.ts`, `image-resolve.ts` | What a file has to be before it is uploaded (magic-number sniffing, the lexicon's allowlist and ceiling), and the per-space resolver that turns a `radial-image:` locator into a blob URL on its author's current PDS. | | `src/lib/jsdom-layout.ts` | The two layout methods jsdom lacks and CodeMirror calls. Loaded only as the component suite's vitest setup file. | @@ -302,6 +303,36 @@ dropped from another tab stays text. See design §4 for why the body names the record rather than a URL, and what a reader is allowed to fetch as a result. +### Naming another record in a body + +Typing `[[` in any of those fields offers the space's goals, artifacts, thread messages and projects, +and picking one writes `[](at://did/collection/rkey)` — an ordinary markdown link +naming an ordinary record (`links.ts`). Ctrl-Space opens the same list explicitly; Escape closes it +and leaves the `[[` standing as the text it is, and a second Escape leaves the field, which is the +app's one-layer-per-press rule. What is typed after `[[` filters by each row's OWN quick-find +projection, so a record found by a word in a list is found by that word here. + +`[[` because it means nothing in markdown, is the wiki-link idiom, and is neither `@` (an email +address) nor `#` (a heading). The URI rather than an app URL for the same reason `radial-image:` names +a record (design §4): it survives its author moving PDS and this app moving origin, it is what the CLI +and every turn bundle already use, and an agent reading the body gets something it can already act on. +Nothing about the fold changes — no lexicon, no materializer, no daemon — and the body is one an older +client already renders. + +It is offered wherever the composing surface has a space in scope: goal bodies, thread messages and +replies, request briefs, the two ask cards, review findings, and the guest composer. It is **off** for +an artifact type's template, on the images precedent above and for the same reason — a template is +copied into every turn, and what a space-local link means there is a question nobody has answered. The +textarea half offers no completion at all, the same degradation contract highlighting has: the field +works, the feature is absent, and pasting a URI by hand produces the identical body. + +Read back, a link whose URI names a record of the space being read is an ordinary in-app link; +anything else — another space, a record this tab has not synced, a malformed URI — keeps the inert +chip with the full URI in its title. The lookup is exact string equality against the local index and +never a fetch, so what a hostile body can cause is a dictionary miss. **The label is a snapshot.** A +goal renamed after being linked keeps its old label in old bodies, exactly as every other word in an +immutable record does; the href follows the live index, so the link still goes to the right place. + ### Link previews A crawler does not run the app. Paste a Radial URL into Slack, Discord or a social feed and the diff --git a/packages/ui/package.json b/packages/ui/package.json index 723b7cf..edf26d8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,6 +20,7 @@ "dependencies": { "@atproto/oauth-client-browser": "^0.4.9", "@atproto/oauth-types": "^0.7.5", + "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.4", "@codemirror/lang-markdown": "^6.5.1", "@codemirror/language": "^6.12.4", diff --git a/packages/ui/src/lib/components/AskAnswer.svelte b/packages/ui/src/lib/components/AskAnswer.svelte index a39369a..ee58356 100644 --- a/packages/ui/src/lib/components/AskAnswer.svelte +++ b/packages/ui/src/lib/components/AskAnswer.svelte @@ -1,6 +1,7 @@ -{#each runs as run, index (index)}{#if run.type === 'escape' || run.type === 'codespan'}{#if run.type === 'codespan'}{textOf(run)}{:else}{textOf(run)}{/if}{:else if run.type === 'strong'}{:else if run.type === 'em'}{:else if run.type === 'del'}{:else if run.type === 'br'}
{:else if run.type === 'link'}{#if link(run)}{:else if isAtUri(hrefOf(run))}{:else}{/if}{:else if run.type === 'image'}{#if isImageLocator(hrefOf(run))}{:else if safeImage(hrefOf(run))}{textOf(run)}{:else}{label(run)}{/if}{:else if kids(run).length > 0}{:else}{textOf(run)}{/if}{/each} +{#each runs as run, index (index)}{#if run.type === 'escape' || run.type === 'codespan'}{#if run.type === 'codespan'}{textOf(run)}{:else}{textOf(run)}{/if}{:else if run.type === 'strong'}{:else if run.type === 'em'}{:else if run.type === 'del'}{:else if run.type === 'br'}
{:else if run.type === 'link'}{#if link(run)}{:else if record(run)}{:else if isAtUri(hrefOf(run))}{:else}{/if}{:else if run.type === 'image'}{#if isImageLocator(hrefOf(run))}{:else if safeImage(hrefOf(run))}{textOf(run)}{:else}{label(run)}{/if}{:else if kids(run).length > 0}{:else}{textOf(run)}{/if}{/each} diff --git a/packages/ui/src/lib/components/UnitDetail.svelte b/packages/ui/src/lib/components/UnitDetail.svelte index be29ae8..e90d62d 100644 --- a/packages/ui/src/lib/components/UnitDetail.svelte +++ b/packages/ui/src/lib/components/UnitDetail.svelte @@ -9,6 +9,7 @@ requestDialogId, } from '$lib/compose.svelte.js' import { artifactLabel, shortCommit, stamp } from '$lib/format.js' + import { linkCandidates } from '$lib/links.js' import { inline } from '$lib/prose.js' import { buildableTypes, @@ -399,6 +400,7 @@ minHeight="42px" disabled={replyBusy} images + links={() => linkCandidates(space)} onSubmit={() => void postReply(question, asker)} placeholder="Answer {asker.handle ?? asker.name} — the request is picked up again on the next poll" ariaLabel="Reply to the question" diff --git a/packages/ui/src/lib/components/Verdict.svelte b/packages/ui/src/lib/components/Verdict.svelte index eb8d237..fb2f8f4 100644 --- a/packages/ui/src/lib/components/Verdict.svelte +++ b/packages/ui/src/lib/components/Verdict.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte' import type { UnitVersion, UnitView, Verdict } from '@radial/core' import { account } from '$lib/auth.svelte.js' + import { linkCandidates } from '$lib/links.js' import { toast, ui } from '$lib/ui.svelte.js' import type { Space } from '$lib/space.js' import { pinnedLabel } from '$lib/units.js' @@ -197,6 +198,7 @@ minHeight="52px" disabled={writing} images + links={() => linkCandidates(space)} placeholder="What is wrong, and what would make it right" ariaLabel="Finding {index + 1}" /> diff --git a/packages/ui/src/lib/editor.ts b/packages/ui/src/lib/editor.ts index 4ac2802..a8377df 100644 --- a/packages/ui/src/lib/editor.ts +++ b/packages/ui/src/lib/editor.ts @@ -13,6 +13,7 @@ import type { Extension } from '@codemirror/state' import type { EditorView, KeyBinding } from '@codemirror/view' +import { linkCompletion, type LinkCandidate } from './links.js' export interface EditorModules { EditorState: typeof import('@codemirror/state').EditorState @@ -27,6 +28,16 @@ let loading: Promise export interface BaseOptions { /** ⌘↵ / Ctrl↵. The composers bind it to their own submit; a card with no default action omits it. */ onSubmit?: (() => void) | undefined + /** + * What `[[` may name (`links.ts`), where the surface has a space in scope. Absent switches record + * completion off entirely — an artifact type's TEMPLATE gets no candidates, for the same reason it + * gets no image button: it is copied into every turn, and what a space-local link means there is a + * question nobody has answered. + * + * A getter rather than a list, so the ten-second poll refreshes the candidates without the editor + * being reconfigured — a rebuild mid-draft would cost the undo history and the caret. + */ + links?: (() => LinkCandidate[]) | undefined } /** @@ -64,13 +75,14 @@ export function loadEditor(): Promise Extension[] }> { - const [state, view, commands, language, markdownLanguage, highlight] = await Promise.all([ + const [state, view, commands, language, markdownLanguage, highlight, autocomplete] = await Promise.all([ import('@codemirror/state'), import('@codemirror/view'), import('@codemirror/commands'), import('@codemirror/language'), import('@codemirror/lang-markdown'), import('@lezer/highlight'), + import('@codemirror/autocomplete'), ]) const { tags } = highlight @@ -113,8 +125,74 @@ async function build(): Promise ul': { + fontFamily: 'inherit', + // The field's own size, from the same token the content takes: the list is read at the size of + // what is being typed into, and it rises with the touch floor along with it (`app.css`). + fontSize: 'var(--field-size)', + maxHeight: '15em', + // A record's title is prose and wraps nowhere else in this app either; a list that stretched + // to the longest goal in the space would be a list that changes width as it is filtered. + maxWidth: 'min(420px, calc(100vw - 32px))', + }, + '.cm-tooltip-autocomplete > ul > li': { + padding: '4px 10px', + color: 'var(--ink)', + lineHeight: '1.45', + }, + '.cm-tooltip-autocomplete > ul > li[aria-selected]': { + background: 'var(--accent-wash)', + color: 'var(--ink)', + }, + // Where it hangs off, in the app's secondary ink rather than in italics — a project name and a + // goal's title are both prose, and slanting one of them says they are different kinds of word. + // A step down from the row rather than a size of its own, so it is one token all the way up. + '.cm-completionDetail': { + marginLeft: '8px', + fontStyle: 'normal', + fontSize: '0.86em', + color: 'var(--ink-2)', + }, }) + /** + * Naming another record from inside the body: type `[[`, pick, and a markdown link to it lands + * (`links.ts` holds the rule; this is the wiring). + * + * `override` rather than a source added to the defaults, because there are no other sources here + * and a word-completion list in a prose field would be an editor guessing at English. `icons: false` + * for the same reason the highlighting is what it is: the list says what a record is called and + * where it hangs off, and a glyph column would be a second vocabulary beside the one every row in + * this app already uses. + * + * The tooltip is parented to the BODY. `.md-host` is `overflow: hidden` (it has to be — it is the + * field's box), so a list drawn inside it is a list clipped to one line of it; and the compose + * cards it opens over sit on a scrim at `z-index: 50`, which CodeMirror's own 500 clears. + */ + const recordLinks = (options: BaseOptions): Extension[] => { + const links = options.links + if (!links) return [] + return [ + autocomplete.autocompletion({ + override: [(context) => linkCompletion(context, links())], + icons: false, + }), + view.tooltips({ parent: document.body }), + ] + } + const baseExtensions = (options: BaseOptions): Extension[] => { // Escape LEAVES the field; it never discards what is in it. That is the app's rule for every // field (`keys.ts`), and it has to be stated explicitly here because a textarea got it from the @@ -123,6 +201,12 @@ async function build(): Promise { + // One layer per press, which is the app-wide rule (`keys.ts`). With the `[[` list open, + // Escape belongs to the list — and `completionKeymap` is registered at the highest + // precedence by `autocompletion()`, so it has already closed it and this never runs. The + // check is here anyway: the ordering is another package's decision, and the failure it + // would cause — one Escape closing the list AND blurring the field mid-pick — is silent. + if (autocomplete.completionStatus(target.state) !== null) return false target.contentDOM.blur() return true }, @@ -160,6 +244,7 @@ async function build(): Promise { expect(labelCorpus(goal(['infra', 'needs design']))).toBe('infra needs design') expect(labelCorpus(goal([]))).toBe('') }) + + it('searches a goal by its title, its body and its labels, in one projection', () => { + // One function, because the goal list and the `[[` completion (`links.ts`) both search a goal by + // it: a word visible on a row that finds the row and not its completion is the drift this avoids. + const titled = { ...goal(['infra']), target: { value: { title: 'Ship it', body: 'the whole fold' } } } + expect(goalSearchText(titled as unknown as GoalView)).toBe('Ship it the whole fold infra') + }) }) diff --git a/packages/ui/src/lib/filters.ts b/packages/ui/src/lib/filters.ts index e2cc87f..447ea5e 100644 --- a/packages/ui/src/lib/filters.ts +++ b/packages/ui/src/lib/filters.ts @@ -52,6 +52,18 @@ export const applyFilter = (goals: readonly GoalView[], filter: GoalFilter): Goa /** The label text a quick-find query should also search, so typing a label name finds its goals. */ export const labelCorpus = (goal: GoalView): string => goal.labels.join(' ') +/** + * A goal row as quick find searches it: its title, its body and its labels. + * + * The goal's projection, beside the model its row is drawn from, the way `unitRowSearchText` sits in + * `units.ts` and `messageSearchText` in `replies.ts`. One function rather than the corpus spelled out + * on each page that searches it, because a goal is found by these words in the goal list AND in the + * `[[` completion (`links.ts`), and a word visible on a row that finds the row but not its completion + * is the drift this is shaped to avoid. + */ +export const goalSearchText = (goal: GoalView): string => + `${goal.target.value.title} ${goal.target.value.body} ${labelCorpus(goal)}` + const LABEL_PARAM = 'label' const STATE_PARAM = 'state' const PROJECT_PARAM = 'project' diff --git a/packages/ui/src/lib/links.test.ts b/packages/ui/src/lib/links.test.ts new file mode 100644 index 0000000..26035bb --- /dev/null +++ b/packages/ui/src/lib/links.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest' +import { buildFixtureSpace } from './fixture.js' +import { + linkCandidates, + linkCompletion, + linkMarkdown, + linkMatches, + resolveAtLink, + type CompletionQuery, + type LinkCandidate, +} from './links.js' +import { projectByName, unitsOf } from './space.js' + +// Naming a record from inside a body, in both directions: what a `[[` offers and writes, and where +// the URI it wrote goes when the body is read back. +// +// Written against the fixture, like the quick-find tests and for the same reason: the claim being +// made is that a word a reader can SEE on a row finds that row's completion, and the fixture is the +// dataset those rows are drawn from. Nothing here renders anything — the completion rule takes the +// two things a source is asked as a plain interface, so it is held without CodeMirror and without a +// DOM (`links.ts` on why it is shaped that way). + +const space = buildFixtureSpace() +const candidates = linkCandidates(space) +const of = (kind: LinkCandidate['kind']): LinkCandidate[] => + candidates.filter((candidate) => candidate.kind === kind) + +const named = (title: string): LinkCandidate => { + const found = candidates.find((candidate) => candidate.title === title) + if (!found) throw new Error(`fixture candidate missing: ${title}`) + return found +} + +/** + * A caret at the end of `text`, as a completion source is asked about it. + * + * `matchBefore` is `CompletionContext`'s: the expression is anchored to the caret and the FIRST + * position it matches from wins, which is what makes `[[[` open on the second bracket rather than + * refusing. + */ +const at = (text: string, explicit = false): CompletionQuery => ({ + explicit, + pos: text.length, + matchBefore(expr) { + const line = text.slice(text.lastIndexOf('\n') + 1) + const found = line.search(new RegExp(`(?:${expr.source})$`, expr.flags.replace('g', ''))) + if (found < 0) return null + const from = text.length - line.length + found + return { from, to: text.length, text: line.slice(found) } + }, +}) + +describe('linkCandidates', () => { + it('offers every kind of record in the space', () => { + expect(of('goal').length).toBe(space.index.goals.length) + expect(of('project').length).toBe(space.index.projects.length) + expect(of('artifact').length).toBeGreaterThan(0) + expect(of('message').length).toBeGreaterThan(0) + }) + + it('names a unit by its key, so what is written is what resolves', () => { + const project = projectByName(space.index, 'radial-ng') + if (!project) throw new Error('fixture project missing: radial-ng') + const architecture = unitsOf(space.index, project).find((unit) => unit.type === 'architecture') + expect(architecture).toBeDefined() + const candidate = of('artifact').find((row) => row.uri === architecture?.key) + // The TIP's name, not v1's: a living document renamed at v3 is called what it is called now. + expect(candidate?.title).toBe('Four moving parts and the write boundary') + expect(candidate?.detail).toBe('architecture · System · radial-ng') + }) + + it('calls a unit with nothing landed by its type, since there is no record to name it', () => { + const lone = of('artifact').filter((row) => row.uri.includes('/com.disnetdev.radial.artifactRequest/')) + expect(lone.length).toBeGreaterThan(0) + // The type, and the type alone: nothing has landed, so there is no title, no heading and no + // first sentence to call it by. It is keyed by the driving request for the same reason. + expect(lone.every((row) => row.detail.startsWith(`${row.title} · `))).toBe(true) + }) + + it('keeps ended goals, because a reference resolves wherever it points', () => { + // The lookup fold, not the live one. Shelving a goal must not turn a link into it back into an + // rkey — and the ending is in the detail, so the list says what it is offering. + const ended = of('goal').filter((row) => / · (completed|dropped|parked|superseded)$/.test(row.detail)) + expect(ended.length).toBeGreaterThan(0) + }) + + it('ranks the thread last, so a busy space still offers its goals first', () => { + const kinds = candidates.map((candidate) => candidate.kind) + const firstMessage = kinds.indexOf('message') + expect(firstMessage).toBeGreaterThan(0) + expect(kinds.slice(firstMessage).every((kind) => kind === 'message')).toBe(true) + }) + + it('is memoised per fold, so a keystroke costs a filter and not a timeline', () => { + expect(linkCandidates(space)).toBe(candidates) + }) +}) + +describe('linkMatches', () => { + it('finds a record by a word on its row', () => { + const goal = named('Surface the records the fold ignored') + expect(linkMatches(goal, 'ignored')).toBe(true) + expect(linkMatches(goal, 'IGNORED')).toBe(true) + expect(linkMatches(goal, ' ignored ')).toBe(true) + expect(linkMatches(goal, 'nothing in this space says this')).toBe(false) + }) + + it('finds an artifact by its body, the way its row does', () => { + // Quick find searches a body as well as a title, because a short title is how a record is found + // by name and it must not cost the full-text search that was there before titles existed. + const architecture = named('Four moving parts and the write boundary') + // `ingest pulls member repos` is in the document's prose and nowhere in its title. + expect(linkMatches(architecture, 'pulls member repos')).toBe(true) + expect(architecture.title.toLowerCase()).not.toContain('pulls member repos') + }) + + it('offers everything for an empty query', () => { + expect(candidates.every((candidate) => linkMatches(candidate, ' '))).toBe(true) + }) +}) + +describe('linkMarkdown', () => { + it('writes an ordinary markdown link naming the record', () => { + const goal = named('Operator onboarding page') + expect(linkMarkdown(goal)).toBe(`[Operator onboarding page](${goal.uri})`) + }) + + it('defuses the brackets and the backslash in a title', () => { + const candidate: LinkCandidate = { + kind: 'goal', + uri: 'at://did:plc:abc/com.disnetdev.radial.goal/3lba', + title: 'the [[ trigger \\ and its ]', + detail: '', + search: '', + } + // Left alone this would stop being a link halfway through its own label. + expect(linkMarkdown(candidate)).toBe( + '[the \\[\\[ trigger \\\\ and its \\]](at://did:plc:abc/com.disnetdev.radial.goal/3lba)', + ) + }) + + it('writes one line, whatever the title did', () => { + const candidate: LinkCandidate = { + kind: 'message', + uri: 'at://did:plc:abc/com.disnetdev.radial.message/3lba', + title: ' two\nlines ', + detail: '', + search: '', + } + expect(linkMarkdown(candidate)).toBe('[two lines](at://did:plc:abc/com.disnetdev.radial.message/3lba)') + }) + + it('falls back to the kind rather than writing an empty label', () => { + const candidate: LinkCandidate = { kind: 'artifact', uri: 'at://x/y/z', title: ' ', detail: '', search: '' } + expect(linkMarkdown(candidate)).toBe('[artifact](at://x/y/z)') + }) +}) + +describe('linkCompletion', () => { + it('opens on `[[` and matches back to the opening brackets', () => { + const offer = linkCompletion(at('see [[onboard'), candidates) + expect(offer).not.toBeNull() + expect(offer?.from).toBe(4) + const goal = named('Operator onboarding page') + expect(offer?.options.some((option) => option.apply === linkMarkdown(goal))).toBe(true) + }) + + it('replaces the whole `[[query` span, so accepting leaves finished markdown', () => { + const text = 'see [[onboard' + const offer = linkCompletion(at(text), candidates) + const option = offer?.options.find((row) => row.label === 'Operator onboarding page') + expect(option).toBeDefined() + const applied = text.slice(0, offer?.from ?? 0) + (option?.apply ?? '') + expect(applied).toBe(`see [Operator onboarding page](${named('Operator onboarding page').uri})`) + }) + + it('stays shut on ordinary prose', () => { + expect(linkCompletion(at('a plain sentence'), candidates)).toBeNull() + expect(linkCompletion(at('one [ bracket'), candidates)).toBeNull() + // A finished link is not an open trigger, and neither is a `[[` on the line above. + expect(linkCompletion(at('[[done]] and then'), candidates)).toBeNull() + expect(linkCompletion(at('[[left standing\nnext line'), candidates)).toBeNull() + }) + + it('opens on an explicit ask with nothing typed, inserting at the caret', () => { + const offer = linkCompletion(at('mid sentence ', true), candidates) + expect(offer?.from).toBe('mid sentence '.length) + expect(offer?.options.length).toBeGreaterThan(0) + }) + + it('closes rather than offering an empty list', () => { + expect(linkCompletion(at('[[nothing in this space says this'), candidates)).toBeNull() + }) + + it('filters itself, and says so, so nothing filters it a second way', () => { + const offer = linkCompletion(at('[[onboard'), candidates) + expect(offer?.filter).toBe(false) + expect(offer?.options.every((option) => option.label.length > 0)).toBe(true) + }) + + it('bounds one list without making anything unfindable', () => { + const many: LinkCandidate[] = Array.from({ length: 400 }, (_, index) => ({ + kind: 'goal', + uri: `at://did:plc:abc/com.disnetdev.radial.goal/3lba${index}`, + title: `goal ${index}`, + detail: 'goal · x', + search: `goal ${index}`, + })) + expect(linkCompletion(at('[['), many)?.options.length).toBe(60) + // …and the four hundredth is still one query away. + expect(linkCompletion(at('[[goal 399'), many)?.options).toHaveLength(1) + }) +}) + +describe('resolveAtLink', () => { + const where = (uri: string): string | undefined => resolveAtLink(space.index, space.uri, uri) + const goal = space.index.goals[0] + if (!goal) throw new Error('fixture has no goals') + + it('sends a goal to its page, carrying the space', () => { + const href = where(goal.target.uri) + expect(href).toContain(`/g/${goal.target.did}/${goal.target.rkey}`) + expect(href).toContain(`space=${encodeURIComponent(space.uri)}`) + }) + + it('sends a unit to its drawer, under the goal it hangs off', () => { + const unit = named('Six golden scenarios, shuffled 500 ways') + expect(where(unit.uri)).toContain(`unit=${encodeURIComponent(unit.uri)}`) + expect(where(unit.uri)).toContain('/g/') + }) + + it('sends a project-hung unit to System rather than to the project’s goal list', () => { + const architecture = named('Four moving parts and the write boundary') + expect(where(architecture.uri)).toContain('/p/radial-ng/system?') + expect(where(architecture.uri)).toContain(`unit=${encodeURIComponent(architecture.uri)}`) + }) + + it('sends a unit with nothing landed to the drawer its request keys', () => { + const lone = of('artifact').find((row) => row.uri.includes('/com.disnetdev.radial.artifactRequest/')) + expect(lone).toBeDefined() + expect(where(lone?.uri ?? '')).toContain(`unit=${encodeURIComponent(lone?.uri ?? '')}`) + }) + + it('sends a message to the page its exchange is on', () => { + const message = of('message')[0] + expect(message).toBeDefined() + expect(where(message?.uri ?? '')).toMatch(/^\/g\/did:plc:/) + }) + + it('sends a project to its own page', () => { + const project = named('radial-ng') + expect(where(project.uri)).toContain('/p/radial-ng?') + }) + + it('resolves every candidate it offers — what is written is what is followed', () => { + for (const candidate of candidates) expect(where(candidate.uri)).toBeDefined() + }) + + it('answers nothing for anything it does not hold, and never fetches to find out', () => { + // A body is untrusted text from another member's repo. Every one of these is a dictionary miss + // and nothing else: the caller draws the chip it has always drawn. + expect(where('at://did:plc:stranger/com.disnetdev.radial.goal/3lba')).toBeUndefined() + expect(where('at://did:plc:qv7hjr2mzk4x/app.bsky.feed.post/3lba')).toBeUndefined() + expect(where(`${goal.target.uri}#and-a-fragment`)).toBeUndefined() + expect(where('at://')).toBeUndefined() + expect(where('https://radl.app/g/did:plc:abc/3lba')).toBeUndefined() + expect(where('javascript:alert(1)')).toBeUndefined() + expect(where('')).toBeUndefined() + }) + + it('reads a padded URI, since markdown keeps the spaces a writer left', () => { + expect(where(` ${goal.target.uri} `)).toBe(where(goal.target.uri)) + }) +}) diff --git a/packages/ui/src/lib/links.ts b/packages/ui/src/lib/links.ts new file mode 100644 index 0000000..eaf48cd --- /dev/null +++ b/packages/ui/src/lib/links.ts @@ -0,0 +1,330 @@ +// Naming another record from inside a body, and following the name back. +// +// A body may already carry `[label](at://did/collection/rkey)`: `prose.ts` recognises the scheme and +// `Runs.svelte` has always drawn one as an inert chip, so nothing here changes what a record IS. What +// this module adds is the two halves that were missing — the candidates an editor offers when +// somebody types `[[`, and the reverse lookup that turns the URI in a rendered body into a page in +// this app. +// +// Three decisions worth keeping: +// +// - **A body names the record, never a page.** The same rule `radial-image:` settled (design §4). +// An `at://` URI survives its author moving PDS and survives this app moving origin, and it is +// what the CLI and every turn bundle already use to name records. The *href* is derived at +// read time, from the index this tab holds. +// - **Resolution is closed.** A URI in a body is untrusted text that can name anything, so it is +// matched by exact string equality against the local index and nothing else. There is no fetch: +// the worst a hostile body can do is miss the dictionary and render as the chip it renders as +// today. This is `image-resolve.ts`'s rule with the network half removed. +// - **The label is a snapshot.** `[Ship the fold](at://…)` keeps saying "Ship the fold" after the +// goal is renamed, exactly as every other word in an immutable body does. The href follows the +// live index, so the link still goes to the right place; only the word is old. +// +// Everything here is pure and holds no CodeMirror: `linkCompletion` takes the two things a +// completion source is asked (what is behind the caret, and whether the reader asked explicitly) as +// an ordinary interface, so the whole rule is testable in the `pure` project and `editor.ts` is left +// holding nothing but the wiring. + +import type { GoalView, MaterializedIndex, ProjectView, UnitView } from '@radial/core' +import type { Directory } from './directory.js' +import { goalSearchText } from './filters.js' +import { artifactLabel, summarize } from './format.js' +import { messageSearchText } from './replies.js' +import { + goalHref, + projectHref, + projectOf, + systemHref, + unitHref, + withSpace, + type Space, +} from './space.js' +import { + isGoalView, + targetLabel, + typeLabel, + unitRowSearchText, + unitsWithContext, +} from './units.js' + +/** What a candidate stands for. Also the option's `type`, which is what the list's rows are keyed by. */ +export type LinkKind = 'goal' | 'artifact' | 'message' | 'project' + +export interface LinkCandidate { + kind: LinkKind + /** + * The `at://` URI the body will name. A unit is named by its `key` — the chain's root artifact, or + * the driving request where nothing has landed — which is the same key `unitHref` deep-links by, + * so what is written and what resolves are one string (§3.7). + */ + uri: string + /** The label the inserted markdown carries. A snapshot; see the header. */ + title: string + /** The second half of the row: what kind of thing it is and where it hangs off. */ + detail: string + /** + * What typing filters against — the row's OWN quick-find projection, so a record found by a word + * in one list is found by that word here. A completion with a corpus of its own is a completion + * that eventually disagrees with the page it was typed on. + */ + search: string +} + +/** + * Everything in this space a body could name, in the order the list offers them: goals, then the + * units under them, then projects, then the thread. + * + * Messages last and deliberately uncapped. They outnumber everything else in a busy space, so + * offering them first would bury the goals — but capping the *candidates* would make an old message + * unfindable by typing its own words, which is worse than a long list nobody scrolls. The bound is + * on what one completion returns (`MAX_OPTIONS`), after filtering. + */ +export function linkCandidates(space: Space): LinkCandidate[] { + const { index, directory } = space + const held = cache.get(index)?.get(directory) + if (held) return held + const candidates = [...goalCandidates(index), ...unitCandidates(index, directory), ...projectCandidates(index), ...messageCandidates(index, directory)] + const perDirectory = cache.get(index) ?? new WeakMap() + cache.set(index, perDirectory) + perDirectory.set(directory, candidates) + return candidates +} + +/** + * Two keys, because two things decide a candidate and they change at different moments. The fold + * decides what exists; the directory decides what a person is CALLED, and a handle resolves after + * the fold that named them — so a message candidate labelled `did:plc:…` would otherwise stay that + * way for the life of the index. + */ +const cache = new WeakMap>() + +function goalCandidates(index: MaterializedIndex): LinkCandidate[] { + return index.goals.map((goal) => { + const project = projectOf(index, goal) + const ending = goal.ended ? ` · ${goal.disposition ?? 'ended'}` : '' + return { + kind: 'goal' as const, + uri: goal.target.uri, + title: goal.target.value.title, + detail: `goal · ${project?.name ?? 'no project'}${ending}`, + search: goalSearchText(goal), + } + }) +} + +function unitCandidates(index: MaterializedIndex, directory: Directory): LinkCandidate[] { + // `unitsWithContext`, not `liveUnits`: this is the lookup fold. A plan under a goal somebody has + // ended is still the plan, and a reference to it is still worth writing down. + return unitsWithContext(index).map(({ unit, target }) => ({ + kind: 'artifact' as const, + uri: unit.key, + title: unitTitle(unit), + detail: `${typeLabel(unit.type)} · ${targetLabel(target)}`, + search: unitRowSearchText(unit, target, directory), + })) +} + +/** + * What to call a unit in the list, and in the body afterwards: `artifactLabel`, which is the app's + * one answer everywhere a landed record is named rather than read — the record's own title, then its + * first heading, then its first sentence, for the versions signed before the field existed. A unit + * with nothing landed has none of those and is called by its type. + */ +const unitTitle = (unit: UnitView): string => + unit.current ? artifactLabel(unit.current.artifact.value) : typeLabel(unit.type) + +function projectCandidates(index: MaterializedIndex): LinkCandidate[] { + return index.projects.map((project) => ({ + kind: 'project' as const, + uri: project.target.uri, + title: project.name, + detail: project.archived ? 'project · archived' : 'project', + search: `${project.name} ${project.gitUrl}`, + })) +} + +/** Newest first, so the exchange somebody is in the middle of is the part of it they are offered. */ +function messageCandidates(index: MaterializedIndex, directory: Directory): LinkCandidate[] { + const out: Array<{ at: string; candidate: LinkCandidate }> = [] + for (const target of [...index.goals, ...index.projects]) { + for (const message of target.messages) { + const who = directory.get(message.did) + out.push({ + at: message.value.createdAt, + candidate: { + kind: 'message', + uri: message.uri, + // The words, not the author: two messages from one person are told apart by what they say. + title: summarize(message.value.body, 80) || 'message', + detail: `message · ${who.name} · ${targetLabel(target)}`, + search: messageSearchText(message, directory), + }, + }) + } + } + return out + .sort((left, right) => right.at.localeCompare(left.at) || left.candidate.uri.localeCompare(right.candidate.uri)) + .map((row) => row.candidate) +} + +// ── inserting one ─────────────────────────────────────────────────────────────────────────────── + +/** The exact string a completion writes into the body. Nothing else composes this. */ +export const linkMarkdown = (candidate: LinkCandidate): string => + `[${escapeLabel(candidate.title) || candidate.kind}](${candidate.uri})` + +/** + * A title as a markdown link LABEL: one line, and its brackets defused. + * + * A goal called "the [[ trigger" would otherwise write markdown that stops being a link, and a + * backslash already in the title would escape whatever followed it — so the backslash goes first. + */ +const escapeLabel = (title: string): string => + title.replace(/\s+/g, ' ').trim().replace(/[\\[\]]/g, (char) => `\\${char}`) + +/** + * Quick find's rule, applied to one candidate: trimmed, case-folded, substring. + * + * Substring rather than CodeMirror's own fuzzy matcher, which is why the completion result carries + * `filter: false`. The projections above include artifact BODIES, and subsequence-matching a body + * makes almost every three letters match almost everything — a second, looser rule for finding a + * record, in an app whose one rule for that is `matches()` in `ui.svelte.ts`. + */ +export const linkMatches = (candidate: LinkCandidate, query: string): boolean => { + const needle = query.trim().toLowerCase() + return needle === '' || candidate.search.toLowerCase().includes(needle) +} + +/** + * What a completion source is asked, as an ordinary interface. + * + * `CompletionContext` from `@codemirror/autocomplete` satisfies this structurally. Taking it this + * way rather than importing the type is what keeps this module free of CodeMirror: `Runs.svelte` + * imports `resolveAtLink` from here, and it is on the read path of every tab that never opens an + * editor at all. + */ +export interface CompletionQuery { + readonly explicit: boolean + readonly pos: number + matchBefore(expr: RegExp): { from: number; to: number; text: string } | null +} + +export interface LinkOption { + label: string + detail: string + type: LinkKind + apply: string +} + +export interface LinkOffer { + from: number + options: LinkOption[] + /** We filtered; CodeMirror must not filter again by a different rule. See `linkMatches`. */ + filter: false +} + +/** + * `[[` and everything typed after it, back to the opening brackets. + * + * `[[` because it has no meaning in markdown, is the wiki-link idiom a reader may already know, and + * is neither `@` (an email address) nor `#` (a heading). A newline or a further bracket ends it, so + * an unfinished `[[` left standing is ordinary text that nothing keeps trying to complete. + */ +const TRIGGER = /\[\[[^[\]\n]*/ + +/** + * How long a list one `[[` may put on screen. Not a limit on what is findable — everything is still + * reachable by typing more — but the tail of a list nobody scrolls costs a busy space real work on + * every keystroke. + */ +const MAX_OPTIONS = 60 + +/** + * The completion source's whole rule: activate on `[[` or on an explicit ask, filter by what has + * been typed since, and replace the `[[query` span with finished markdown. + * + * On an explicit trigger with no `[[` in front of the caret the span is empty, so accepting inserts + * at the caret and leaves what is around it alone. + */ +export function linkCompletion( + context: CompletionQuery, + candidates: readonly LinkCandidate[], +): LinkOffer | null { + const opened = context.matchBefore(TRIGGER) + if (!opened && !context.explicit) return null + const query = opened ? opened.text.slice(2) : '' + const options: LinkOption[] = [] + for (const candidate of candidates) { + if (!linkMatches(candidate, query)) continue + options.push({ + label: candidate.title, + detail: candidate.detail, + type: candidate.kind, + apply: linkMarkdown(candidate), + }) + if (options.length === MAX_OPTIONS) break + } + if (options.length === 0) return null + return { from: opened ? opened.from : context.pos, options, filter: false } +} + +// ── following one back ────────────────────────────────────────────────────────────────────────── + +/** + * Where an `at://` href in a body goes in this app, or nothing. + * + * Nothing is the common answer and is not an error: a URI naming a record in another space, one this + * tab has not synced yet, or one that never existed reads as today's chip with the full URI in its + * title. That is the same "stale, never wrong" posture the freshness chip takes, and it is the only + * thing an unrecognised URI is allowed to cause — resolving by fetching would let a body decide what + * this tab reads. + */ +export function resolveAtLink( + index: MaterializedIndex, + spaceUri: string, + href: string, +): string | undefined { + const uri = href.trim() + if (!uri.startsWith('at://')) return undefined + return destinations(index, spaceUri).get(uri) +} + +/** + * Every URI this space can be navigated to by, built once per (index, space). + * + * Keyed by the space as well as the fold because the `space` parameter rides in every href: the + * same records read under a different space locator produce different links, and a public link that + * dropped it would open on whatever space the reader had last. + */ +const routes = new WeakMap>>() + +function destinations(index: MaterializedIndex, spaceUri: string): Map { + const perSpace = routes.get(index) ?? new Map>() + routes.set(index, perSpace) + const held = perSpace.get(spaceUri) + if (held) return held + + const map = new Map() + const pageOf = (target: GoalView | ProjectView): string => + isGoalView(target) ? goalHref(target, spaceUri) : withSpace(projectHref(target), spaceUri) + + for (const goal of index.goals) map.set(goal.target.uri, goalHref(goal, spaceUri)) + for (const project of index.projects) { + map.set(project.target.uri, withSpace(projectHref(project), spaceUri)) + } + // A unit opens its drawer, and a project-hung one opens it on the System page rather than on the + // project's goal list — the same two bases every cross-goal row already links through. + for (const { unit, target } of unitsWithContext(index)) { + const base = isGoalView(target) ? goalHref(target, spaceUri) : withSpace(systemHref(target), spaceUri) + map.set(unit.key, unitHref(base, unit)) + } + // A message has no address of its own — the thread is a section of the page its target draws — so + // it resolves to that page. A reader who followed one lands where the exchange is. + for (const target of [...index.goals, ...index.projects]) { + const page = pageOf(target) + for (const message of target.messages) map.set(message.uri, page) + } + + perSpace.set(spaceUri, map) + return map +} diff --git a/packages/ui/src/routes/goals/+page.svelte b/packages/ui/src/routes/goals/+page.svelte index 30b0058..d2002bb 100644 --- a/packages/ui/src/routes/goals/+page.svelte +++ b/packages/ui/src/routes/goals/+page.svelte @@ -3,7 +3,7 @@ import FilterBar from '$lib/components/FilterBar.svelte' import Glyph from '$lib/components/Glyph.svelte' import GoalGroups from '$lib/components/GoalGroups.svelte' - import { applyFilter, labelCorpus, narrowing } from '$lib/filters.js' + import { applyFilter, goalSearchText, narrowing } from '$lib/filters.js' import { filter } from '$lib/filters.svelte.js' import { currentSpace } from '$lib/session.svelte.js' import { projectByUri } from '$lib/space.js' @@ -27,9 +27,9 @@ // words are in that text, so typing one finds its goals without pressing a chip. const goals = $derived( filtered - .filter((goal) => - matches(`${goal.target.value.title} ${goal.target.value.body} ${labelCorpus(goal)}`), - ) + // `goalSearchText` rather than the corpus spelled out here, because the `[[` completion offers + // goals by the same projection: a word that finds a row must find the row's completion too. + .filter((goal) => matches(goalSearchText(goal))) // Newest first, by when the goal was opened. A goal list with no project to group it under is // otherwise in URI order, which is an order about DIDs and rkeys rather than about work. .sort((left, right) => diff --git a/packages/ui/test/record-links.test.mjs b/packages/ui/test/record-links.test.mjs new file mode 100644 index 0000000..1e1ae14 --- /dev/null +++ b/packages/ui/test/record-links.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { describe, it } from 'node:test' + +// Linking one record from inside another's body is one module (`src/lib/links.ts`), one prop on the +// one editor, and one branch in the one renderer. What each of those DOES is held in +// `src/lib/links.test.ts` and `MarkdownEditor.svelte.test.ts`; what is checked here is the wiring — +// which surfaces offer it, which deliberately do not, and that the rule stayed in one place. +// +// The same shape as `quick-find.test.mjs`, and for the same reason: the way a feature like this +// breaks is not that the function is wrong, it is that a card nobody re-read stopped calling it. + +const read = (path) => readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8') + +/** Every surface that composes markdown with a space in scope. */ +const OFFERS = [ + 'lib/components/Composer.svelte', + 'lib/components/GuestComposer.svelte', + 'lib/components/NewGoal.svelte', + 'lib/components/NewRequest.svelte', + 'lib/components/AskAnswer.svelte', + 'lib/components/AskReview.svelte', + 'lib/components/Verdict.svelte', + 'lib/components/UnitDetail.svelte', +] + +describe('naming a record from inside a body', () => { + it('is offered on every composing surface that has a space in scope', () => { + for (const path of OFFERS) { + const source = read(path) + assert.match(source, / linkCandidates\(space\)\}/, path) + // A getter over the prop's own space, so the poll loop's next fold is what the next `[[` + // offers — never a list captured when the card opened. + assert.match(source, /import \{ linkCandidates \} from '\$lib\/links\.js'/, path) + } + }) + + it('is off for an artifact type’s template, on the images precedent', () => { + // A template is copied into every turn of its type, and what a space-local link means once it is + // somebody else's standing instruction is a question nobody has answered. The image button is + // absent from this card for exactly the same reason. + const template = read('lib/components/NewType.svelte') + assert.match(template, / { + // A second module composing `[title](at://…)`, or a card building its own candidate list, is how + // what is offered comes to disagree with what resolves. + const composing = [] + for (const path of sources()) { + const source = read(path) + if (path === 'lib/links.ts') continue + if (/linkMarkdown\(|linkCompletion\(/.test(source)) composing.push(path) + } + assert.deepEqual(composing, ['lib/editor.ts']) + }) + + it('resolves at read time in the one renderer, and never by fetching', () => { + const runs = read('lib/components/Runs.svelte') + assert.match(runs, /resolveAtLink\(space\.index, space\.uri, hrefOf\(token\)\)/) + // A hit is an ordinary in-app link — no `target="_blank"`, so SvelteKit navigates it — and a + // miss keeps the chip the app has always drawn, full URI in the title. + assert.match(runs, /\{:else if record\(run\)\}/) + assert.match(runs, /\{:else if isAtUri\(hrefOf\(run\)\)\}/) + // `safeHref`'s allowlist is untouched: an `at://` href still never reaches a navigation directly. + assert.match(read('lib/prose.ts'), /url\.protocol === 'http:' \|\| url\.protocol === 'https:' \|\| url\.protocol === 'mailto:'/) + assert.doesNotMatch(read('lib/links.ts'), /fetch\(|getRecord\(/) + }) + + it('finds a record by the words its own row is found by', () => { + // The goals list and the completion read one projection. Two corpora is how a word visibly on a + // row becomes a word that finds the row and not its completion. + assert.match(read('lib/filters.ts'), /export const goalSearchText/) + assert.match(read('lib/links.ts'), /search: goalSearchText\(goal\)/) + assert.match(read('routes/goals/+page.svelte'), /matches\(goalSearchText\(goal\)\)/) + assert.match(read('lib/links.ts'), /unitRowSearchText\(unit, target, directory\)/) + assert.match(read('lib/links.ts'), /messageSearchText\(message, directory\)/) + }) +}) + +/** Every `.ts`/`.svelte` under `src` that ships — tests excluded, like `browser-bundle.test.mjs`. */ +function sources(directory = '') { + const base = new URL(`../src/${directory}`, import.meta.url) + const out = [] + for (const entry of readdirSync(base, { withFileTypes: true })) { + const path = directory ? `${directory}/${entry.name}` : entry.name + if (entry.isDirectory()) out.push(...sources(path)) + else if (/\.(ts|svelte)$/.test(entry.name) && !/\.test\.ts$/.test(entry.name)) out.push(path) + } + return out.sort() +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9451ff7..30ad133 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: '@atproto/oauth-types': specifier: ^0.7.5 version: 0.7.5 + '@codemirror/autocomplete': + specifier: ^6.20.3 + version: 6.20.3 '@codemirror/commands': specifier: ^6.10.4 version: 6.10.4 -- 2.51.2