@gjermundgaraba-designs/pixel-paper 0.20.1 → 0.21.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.
package/DESIGN.md CHANGED
@@ -1057,7 +1057,7 @@ The printed button: a paper rectangle in a 2px ink frame that casts a 3px shadow
1057
1057
  - **Banner** → `div.banner > span.glyph (.error, .ok, .quiet) + span`: a fixed paper slip on the 3px shadow with a 26px glyph box in the Notice's tones and a sentence that says what happens to the reader's work. It holds a state the whole interface is in, for as long as it lasts, one at a time. It is not a live region (it arrives already holding its sentence, which would go unheard): the application says the state once, when it begins, with the `Announcer.Announce` Command. `"Error"` (the default; red, bang): a state that stops work (offline). `"Warn"` (amber): a state that is passing (reconnecting). `"Ok"` (ink, check): the state just ended (back online), shown briefly and then taken away. `"Quiet"` (head, info): news. With the connection word it is one pair: LinkState `"Reconnecting"` beside a warn Banner, `"Offline"` beside an error Banner with a retry, then `"Online"` beside an ok Banner for a moment; `"Connecting"` takes none.
1058
1058
  - **Rail** (Submodel) → `div.railed > main.deck#main + aside.rail[popover="auto"][aria-label]`: the rail's panels (a tree, places, filters) beside the deck's, after it in the markup so the page's main content and its h1 come first in reading order; the stylesheet fixes the rail at the left under the top bar. One element serves both layouts, so nothing in it renders twice or loses its state: at rest it stands there (its `popover` attribute is inert until opened), and at 900px and under it is hidden and `span.rail-open > button.btn.small[aria-expanded]` at the top of the deck opens it as a drawer: a slice of the dithered ground, `min(320px, 100vw - 40px)` wide from the left edge, closed by a 2px ink rule and the 8px card shadow, entering from the left in three steps over a veil. Its head (`div.rail-head`: the name in the label register and a close IconButton) shows only in the drawer. Opening focuses its first control; Escape, a click on the veil, the close glyph or focus leaving closes it, and all but the last return focus to the button. Widening the window past 900px closes it. The parent reads whether the drawer is open with `Rail.isOpen`, to hold its own page keys while the drawer covers the page. A file tree flush in a rail panel (or any panel) drops its own border.
1059
1059
  - **Stage** → `main.stage#main > div.stage-work > div.stage-canvas + div.stage-tools + div.stage-bar`: see Layout. `paused` (a Notice saying why: the link dropped, the page is read-only) veils the work when editing stops: `div.stage-paused[role="status"]` lays the dialog veil and its ink grain over everything and centres that Notice on the 3px button shadow, while the work beneath is `inert`. It lifts when `paused` is not given; the connection word says reconnecting meanwhile.
1060
- - **Window** → `section.panel.window (.flush) > header.panel-tb + div.window-body + div.panel-foot`: a Panel for a stage or a reel, as tall and wide as whatever places it (and moved by it, if anything moves it). Its title bar is the panel's, trimmed to hold tools: 30px tall (`3px 3px 3px 10px`, its left trimmed to 3px under a `lead`), centred, with an optional `lead` (`span.panel-lead`, a quiet IconButton: a Reel's grip), an optional kind glyph, the title (clipped with an ellipsis), a status chip (its border turns paper so it reads on ink), tools and a close IconButton, both in the bar's paper. The body scrolls inside (`16px`, a 14px grid); flush, what fills it (a code surface, a file tree, a diff) drops its border and fills it. A foot is a panel foot; a notice there takes the whole line.
1060
+ - **Window** → `section.panel.window (.flush) > header.panel-tb + div.window-body + div.panel-foot`: a Panel for a stage or a reel, as tall and wide as whatever places it (and moved by it, if anything moves it). Its title bar is the panel's, trimmed to hold tools: 30px tall (`3px 3px 3px 10px`, its left trimmed to 3px under a `lead`), centred, with an optional `lead` (`span.panel-lead`, a quiet IconButton: a Reel's grip), an optional kind glyph, the title (clipped with an ellipsis), a status chip (its border turns paper so it reads on ink), tools and a close IconButton, both in the bar's paper. Tools a child Submodel hands back come as one element, `span.window-tools`, which is no box of its own (`display: contents`), so its buttons sit in the bar as the others do. The body scrolls inside (`16px`, a 14px grid); flush, what fills it (a code surface, a file tree, a diff) drops its border and fills it. A foot is a panel foot; a notice there takes the whole line.
1061
1061
  - **Loading** → `div.loading[role="status"]`: where content is on its way, centred like an EmptyState at `26px 16px`, a 12px uppercase 0.08em (`--tracking-title`) muted line ("loading the items") ending in the 6×11px working cursor. Its status element stays in the page and only its words change: with `isLoading` false it is `div.loading-idle[role="status"]`, empty and out of the flow (`position: absolute`, never `display: none`, which would drop the live region), so it takes no room and no gap at the top of a panel's or a card's body, and a screen reader hears the line when it arrives. The content or an EmptyState takes its place on screen; the line never prints beside either. It is not the page's live region (the Announcer's).
1062
1062
  - **Remote** → spread where content goes: data that loads, in each of Foldkit's `AsyncData` states, with the retry built in, so a failure is never shown without its way back. A Loading line (`loading <label>`) leads in every state, in place, so its status is heard, and holds its words only while the first load runs: a refresh over data already shown is quiet, so a page that polls is not heard each time. Retry takes its button away with the notice, so the update returns focus to the line (its `id`) with the retry, where a first load is heard. A failure with nothing to show is an error Notice titled "<Label> not loaded", the reason (`toErrorText`) under it and a small "retry" button (`onRetry`); a refresh that failed over data already shown (stale) is a warn Notice titled "<Label> not refreshed" with the same reason and retry, above the last data; loaded and refreshing data is the caller's `toDataView` alone, an EmptyState for none included.
1063
1063
  - **Disclosure** (a View over `@foldkit/ui`'s Disclosure; the caller keeps whether it is open) → `div.disclosure > button.disclosure-summary[aria-expanded] + div.disclosure-body`. The summary is a control-register line (`3px 0`) of a 12px `caret-right` glyph, `span.disclosure-label` (a dotted underline on hover) and an optional `span.meta` pushed right; open, the caret turns a quarter in one step (a right angle keeps the grid). The body hangs from the caret on a 1px Rule Grey guide, its content level with the summary's words (`margin-left: 5px`, `padding-left: 14px`), in a 14px grid. For what a reader opens on purpose (grants to edit, details, a danger zone), never to hide the one thing the screen is for.
@@ -1130,7 +1130,7 @@ Focus is the reel's: one pane is focused (`.focused`, its window on the 8px card
1130
1130
 
1131
1131
  ### Inputs / Fields
1132
1132
 
1133
- - **Field** (a View over `@foldkit/ui`'s Input) → `div.field (.boxed, .mono) > label + input + p.field-error + p.field-help`, the label naming the input and the help describing it; a failed field prints why in `p.field-error` (sans, Urgent Red) under the control, which is `aria-invalid` and described by it before its help, and the ErrorSummary above the fields says it again; a required field's control is `aria-required`; the input's `type` the platform's (`"date"` for the browser's date control) when given. **SelectField** (over `@foldkit/ui`'s Select) → `div.field > label + select + p.field-error + p.field-help`, its options in order and a group's under an `optgroup` heading, an option that is listed but not choosable `disabled`; **TextareaField** (over `@foldkit/ui`'s Textarea) → `div.field (.mono) > label + textarea + p.field-error + p.field-help`, opening at `rows` lines. Both name, describe and mark their control as a Field does. **Fieldset** → `fieldset.field > legend.field-label`, then the controls, `p.field-error` and `p.field-help`, its error and its help linked with `aria-describedby`; **FieldGrid** → `div.field-grid` (auto-fit, 160px minimum); **Fields** → `fieldset.stack.gap-m`: a form's fields as one column `--space-m` (14px) apart, a body's gap, on the layout primitives with no rule of its own. `isBusy` (a save in flight) sets `disabled` and `aria-busy` on it, so every control holds at once and nothing changes under the save; the submit Button stays outside it, `isBusy`, so it keeps focus. A disabled control prints its value in Muted Ink; a boxless one also drops its dotted baseline (the mark of a place to type), a boxed one or a select mutes its box and caret.
1133
+ - **Field** (a View over `@foldkit/ui`'s Input) → `div.field (.boxed, .mono) > label + input + p.field-error + p.field-help`, the label naming the input and the help describing it; a failed field prints why in `p.field-error` (sans, Urgent Red) under the control, which is `aria-invalid` and described by it before its help, and the ErrorSummary above the fields says it again; a required field's control is `aria-required`; the input's `type` the platform's (`"date"` for the browser's date control) when given. A field whose place says what it is (a browser's address bar) hides its label (`label.sr-only`), which still names the input. **SelectField** (over `@foldkit/ui`'s Select) → `div.field > label + select + p.field-error + p.field-help`, its options in order and a group's under an `optgroup` heading, an option that is listed but not choosable `disabled`; **TextareaField** (over `@foldkit/ui`'s Textarea) → `div.field (.mono) > label + textarea + p.field-error + p.field-help`, opening at `rows` lines. Both name, describe and mark their control as a Field does. **Fieldset** → `fieldset.field > legend.field-label`, then the controls, `p.field-error` and `p.field-help`, its error and its help linked with `aria-describedby`; **FieldGrid** → `div.field-grid` (auto-fit, 160px minimum); **Fields** → `fieldset.stack.gap-m`: a form's fields as one column `--space-m` (14px) apart, a body's gap, on the layout primitives with no rule of its own. `isBusy` (a save in flight) sets `disabled` and `aria-busy` on it, so every control holds at once and nothing changes under the save; the submit Button stays outside it, `isBusy`, so it keeps focus. A disabled control prints its value in Muted Ink; a boxless one also drops its dotted baseline (the mark of a place to type), a boxed one or a select mutes its box and caret.
1134
1134
  - **Label:** 10px uppercase 0.1em Muted Ink, 4px above; turns red when the field is invalid, as a fieldset's legend does when a control in it is.
1135
1135
  - **Boxless (default):** full width, sans body, no box, a 1px dotted Muted Ink baseline, `padding: 2px 0`. On keyboard the baseline becomes 2px solid ink; invalid, 2px solid red.
1136
1136
  - **Boxed:** the field that is the whole job of its form (sign-in, a quick-add field): 2px ink border, `padding: 5px 8px`; keyboard ring 2px outside; invalid turns the border red. A date is always boxed.
@@ -1164,7 +1164,7 @@ Focus is the reel's: one pane is focused (`.focused`, its window on the 8px card
1164
1164
  - **CodeSurface** → `pre.code-surface[tabindex="0"][role="group"]`, named by its label: a 2px ink-bordered head-tinted block in 12px/1.6 system mono, `10px 12px`, `tab-size: 2`, wrapping. It takes a string or a highlighter's spans: `tok-keyword`; `tok-string`, `tok-string2`, `tok-url`; `tok-number`, `tok-bool`, `tok-atom`, `tok-literal`; `tok-definition`, `tok-variableName2`, `tok-macroName`, `tok-link` (underlined); `tok-typeName`, `tok-className`, `tok-namespace`; `tok-comment`, `tok-meta`, `tok-punctuation` take their syntax roles; `tok-heading` and `tok-strong` are bold, `tok-emphasis` italic, `tok-inserted` green ink, `tok-deleted` and `tok-invalid` red ink (invalid on a dotted underline). Anything unnamed stays ink. **CodeEditor** (a View over `@foldkit/ui`'s Textarea) → `textarea.code-surface`, spelling, capitals and autocomplete off: 220px minimum, grows to 60vh, vertically resizable, scrolls sideways (wraps on phones).
1165
1165
  - **Secret** → `div.secret > code[role="group"] + button.btn.small`, the code named by its label: the same box at `6px 6px 6px 12px` with a scrolling nowrap code at 0.02em (wrapping anywhere on a phone, so the end is never hidden) and, in its `actions`, a copy button whose word turns "copied". **CopyButton** (Submodel; `CopyButton.isCopied` is for a parent that follows the copy) → the quiet copy glyph (a check once copied) or, as `"Button"`, a small button whose word turns "copied", for a beat; either says it to screen readers, and a copy that fails says so. It writes through Effect's `Clipboard` service: the application provides `Clipboard.layer` from `@effect/platform-browser` in its `resources`. A `div.save-row` with a `span.push` holds what follows it (the "I've stored it" commit).
1166
1166
  - **History** → `ol.history-list (.checks) > li (.current, [aria-current="true"]) > span.v + span.who + span.what (.failed)`: ruled rows, the version in 11px pixel face in a fixed `4ch + 8px` box (inverted when current, so nothing moves, a Time in it taking the paper too), who in 10px muted at 10ch (20ch for checks), what in sans, a failed line red with "(failed)" after it for screen readers (`span.sr-only`), so the state never rests on colour. `isWrapped` → `ol.history-list.wrapped` (never `.wrap`, which is Wrap's row), for an activity log whose words matter more than a tidy column, compact enough for a rail's panel: when (a whole timestamp, its box as wide as it, muted and flush with the what under it, padded `0 4px` and inverted only when current) and who share the first line with the action pushed to its end, and what runs on its own line below in the note size (12px/1.45 sans, in ink), wrapping anywhere. Entries are `6px 0` apart over 1px rules with a 2px gap between their lines; there is no rule above the first and no padding before the first or after the last, so the list starts where a body's content starts.
1167
- - **LineDiff** → `div.diff-wrap > table.diff > tr.add | tr.del | tr.same | tr.fold`: a Myers line diff (jsdiff's `diff`, a runtime dependency) in a 2px ink box on paper that scrolls sideways; 12px/1.6 system-mono lines, each with two 10px pixel-face line numbers behind a 1px rule, a sign column, and the line. Added lines are Head Tint behind an ink `+`; deleted lines muted behind a red `−`; unchanged runs beyond two lines of context fold into a head-tinted row labelled in 10px uppercase On Head. On phones lines wrap. `LineDiff.patchView` draws the same table from one file's unified diff, the lines between its hunks folded into the counted row; anything else prints a note line.
1167
+ - **LineDiff** → `div.diff-wrap > table.diff > tr.add | tr.del | tr.same | tr.fold`: a Myers line diff (jsdiff's `diff`, a runtime dependency) in a 2px ink box on paper that scrolls sideways; 12px/1.6 system-mono lines, each with two 10px pixel-face line numbers behind a 1px rule, a sign column, and the line. Added lines are Head Tint behind an ink `+`; deleted lines muted behind a red `−`; unchanged runs beyond two lines of context fold into a head-tinted row labelled in 10px uppercase On Head. On phones lines wrap. `LineDiff.patchView` draws the same table from one file's unified diff, the lines between its hunks folded into the counted row. What git's header says prints as one note line, above the table or in its place: a rename (its path unquoted), a mode change, a new or deleted empty file, a binary change; a file whose type changed (a file that became a symlink) is one note over a table for each side, named for its side, the note and tables in a `div.stack.gap-s`. A lone table or note is drawn bare, so a flush window's body still meets it. Text that is not a diff, or holds several files, prints a note line that says it cannot be shown as lines. `LineDiff.describePatch` gives the notes, for a caller that prints them itself.
1168
1168
 
1169
1169
  ### Meters and cells
1170
1170
 
@@ -24,6 +24,9 @@ export type ViewConfig<Message> = Readonly<{
24
24
  isBoxed?: boolean;
25
25
  /** Code and identifiers: the system monospace. */
26
26
  isMono?: boolean;
27
+ /** The label is for screen readers only (`.sr-only`): a field whose place says what it is,
28
+ * such as a browser's address bar. It still names the input. */
29
+ isLabelHidden?: boolean;
27
30
  /** The input's type (`"date"` for the browser's date control, always boxed); text when not
28
31
  * given. */
29
32
  type?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../../../src/field/field.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAM9D,wFAAwF;AACxF,eAAO,MAAM,SAAS,GAAI,OAAO,MAC3B,MAAM,SACH,MAAM,GAAG,SAAS,KACtB,WAAW,CAAC,OAAO,CAAC,KACtB,aAAa,CAAC,IAAI,CACmE,CAAC;AAEzF;;+EAE+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAClC,MAAM,SACH,MAAM,GAAG,SAAS,WAChB,OAAO,KACb,WAAW,CAAC,OAAO,CAAC,KACtB,aAAa,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAGqD,CAAC;AAIjG,4DAA4D;AAC5D,MAAM,MAAM,UAAU,CAAC,OAAO,IAAI,QAAQ,CAAC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IACpC,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;uCAEmC;IACnC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,0FAA0F;IAC1F,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kDAAkD;IAClD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;iBACa;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;iBACa;IACb,eAAe,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,CAAC;CACvC,CAAC,CAAC;AAEH;;;6EAG6E;AAC7E,eAAO,MAAM,IAAI,GAAI,OAAO,UAAU,UAAU,CAAC,OAAO,CAAC,KAAK,WAAW,CAAC,OAAO,CAAC,KAAG,IA4CpF,CAAC"}
1
+ {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../../../src/field/field.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAM9D,wFAAwF;AACxF,eAAO,MAAM,SAAS,GAAI,OAAO,MAC3B,MAAM,SACH,MAAM,GAAG,SAAS,KACtB,WAAW,CAAC,OAAO,CAAC,KACtB,aAAa,CAAC,IAAI,CACmE,CAAC;AAEzF;;+EAE+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAClC,MAAM,SACH,MAAM,GAAG,SAAS,WAChB,OAAO,KACb,WAAW,CAAC,OAAO,CAAC,KACtB,aAAa,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAGqD,CAAC;AAIjG,4DAA4D;AAC5D,MAAM,MAAM,UAAU,CAAC,OAAO,IAAI,QAAQ,CAAC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IACpC,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;uCAEmC;IACnC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,0FAA0F;IAC1F,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kDAAkD;IAClD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;qEACiE;IACjE,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;iBACa;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;iBACa;IACb,eAAe,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,CAAC;CACvC,CAAC,CAAC;AAEH;;;6EAG6E;AAC7E,eAAO,MAAM,IAAI,GAAI,OAAO,UAAU,UAAU,CAAC,OAAO,CAAC,KAAK,WAAW,CAAC,OAAO,CAAC,KAAG,IA6CpF,CAAC"}
@@ -15,7 +15,7 @@ export const describedByError = (id, error, hasHelp, h) => __foldkitBrandViewRes
15
15
  * `aria-invalid` and describes it before the help, and `isRequired` sets `aria-required`.
16
16
  * The input is boxless, a dotted baseline that turns solid ink on focus. */
17
17
  export const view = (config, h) => {
18
- const { id, label, value, onInput, help, error, isRequired = false, isBoxed = false, isMono = false, type, inputAttributes = [], } = config;
18
+ const { id, label, value, onInput, help, error, isRequired = false, isBoxed = false, isMono = false, isLabelHidden = false, type, inputAttributes = [], } = config;
19
19
  return __foldkitBrandViewResult((UiInput.view({
20
20
  id,
21
21
  value,
@@ -24,7 +24,7 @@ export const view = (config, h) => {
24
24
  ...(type !== undefined && { type }),
25
25
  hasDescription: help !== undefined,
26
26
  toView: (groups) => __foldkitBrandViewResult((h.div([h.Class(`field${isBoxed || type === "date" ? " boxed" : ""}${isMono ? " mono" : ""}`)], [
27
- h.label(groups.label, [label]),
27
+ h.label([...groups.label, ...(isLabelHidden ? [h.Class("sr-only")] : [])], [label]),
28
28
  // NOTE: @foldkit/ui gap: Input has no required option, so the wrapper says it.
29
29
  h.input([
30
30
  ...inputAttributes,
@@ -1,3 +1,5 @@
1
1
  export { patchView, view } from "./lineDiff.ts";
2
2
  export type { PatchViewConfig, ViewConfig } from "./lineDiff.ts";
3
+ export { describePatch } from "./patch.ts";
4
+ export type { PatchDescription } from "./patch.ts";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lineDiff/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC;AAChD,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lineDiff/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC;AAChD,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC3C,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC"}
@@ -1 +1,2 @@
1
1
  export { patchView, view } from "./lineDiff.js";
2
+ export { describePatch } from "./patch.js";
@@ -11,7 +11,8 @@ export type ViewConfig = Readonly<{
11
11
  }>;
12
12
  /** Configuration for rendering a unified diff with `patchView`. */
13
13
  export type PatchViewConfig = Readonly<{
14
- /** One file's unified diff, as `diff -u` or `git diff -- <path>` writes it. */
14
+ /** One file's unified diff, as `diff -u` or `git diff -- <path>` writes it, its git header
15
+ * included. */
15
16
  patch: string;
16
17
  /** Names the two sides for screen readers: `"Version 3 against version 4"`. */
17
18
  label: string;
@@ -23,8 +24,11 @@ export declare const view: <Message>(config: ViewConfig, h: HtmlBuilder<Message>
23
24
  /** A unified diff the caller already has (from a server, or `git diff -- <path>`) as `view`'s
24
25
  * table: its hunks' lines, numbered from their headers, and the unchanged lines between hunks
25
26
  * folded into a counted row. Lines past the last hunk are not in the patch, so nothing counts
26
- * them. An empty patch says there is no difference, as `view` does; anything but one file
27
- * with a hunk (text that is not a diff, a binary change, several files) says it cannot be
28
- * shown as lines. */
27
+ * them. What git's header says beyond the lines prints as a note line above the table, or in
28
+ * its place: a rename, a mode change, an empty or binary file (`describePatch` has the
29
+ * sentences). A file whose type changed (a file that became a symlink) is two sections, each
30
+ * its own table under one note, named for its side (`"<label>, as it was"`). An empty patch
31
+ * says there is no difference, as `view` does; text that is not a diff, or holds several
32
+ * files, says it cannot be shown as lines. */
29
33
  export declare const patchView: <Message>(config: PatchViewConfig, h: HtmlBuilder<Message>) => Html;
30
34
  //# sourceMappingURL=lineDiff.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lineDiff.d.ts","sourceRoot":"","sources":["../../../src/lineDiff/lineDiff.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAiBtD,2DAA2D;AAC3D,MAAM,MAAM,UAAU,GAAG,QAAQ,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd;mBACe;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAEH,mEAAmE;AACnE,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;CACf,CAAC,CAAC;AA2EH;;kFAEkF;AAClF,eAAO,MAAM,IAAI,GAAI,OAAO,UAAU,UAAU,KAAK,WAAW,CAAC,OAAO,CAAC,KAAG,IAO3E,CAAC;AAEF;;;;;sBAKsB;AACtB,eAAO,MAAM,SAAS,GAAI,OAAO,UAAU,eAAe,KAAK,WAAW,CAAC,OAAO,CAAC,KAAG,IAWrF,CAAC"}
1
+ {"version":3,"file":"lineDiff.d.ts","sourceRoot":"","sources":["../../../src/lineDiff/lineDiff.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAWtD,2DAA2D;AAC3D,MAAM,MAAM,UAAU,GAAG,QAAQ,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd;mBACe;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAEH,mEAAmE;AACnE,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC;oBACgB;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;CACf,CAAC,CAAC;AAyEH;;kFAEkF;AAClF,eAAO,MAAM,IAAI,GAAI,OAAO,UAAU,UAAU,KAAK,WAAW,CAAC,OAAO,CAAC,KAAG,IAO3E,CAAC;AAEF;;;;;;;;+CAQ+C;AAC/C,eAAO,MAAM,SAAS,GAAI,OAAO,UAAU,eAAe,KAAK,WAAW,CAAC,OAAO,CAAC,KAAG,IAoBrF,CAAC"}
@@ -1,7 +1,8 @@
1
1
  import { brandViewResult as __foldkitBrandViewResult } from 'foldkit/brand'
2
- import { Array, Option, String } from "effect";
2
+ import { Array, Option } from "effect";
3
3
  import { LOCALE } from "../locale/locale.js";
4
- import { DiffRow, diffRowsOf, diffRowsOfPatch, foldUnchanged, hasChanges, } from "./diff.js";
4
+ import { DiffRow, diffRowsOf, foldUnchanged, hasChanges } from "./diff.js";
5
+ import { SAME_NOTE, readPatch } from "./patch.js";
5
6
  // VIEW
6
7
  const DEFAULT_CONTEXT = 2;
7
8
  const COLUMN_COUNT = 4;
@@ -29,7 +30,6 @@ const rowView = (row, h) => __foldkitBrandViewResult((DiffRow.match(row, {
29
30
  Fold: ({ count, firstOldNumber }) => __foldkitBrandViewResult((foldView(count, firstOldNumber, h)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#Fold"),
30
31
  })), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#rowView");
31
32
  const noteView = (note, h) => __foldkitBrandViewResult((h.p([h.Class("note-line")], [note])), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#noteView");
32
- const SAME_NOTE = "No difference: both sides hold the same text.";
33
33
  const tableView = (rows, label, h) => __foldkitBrandViewResult((h.div([h.Class("diff-wrap")], [
34
34
  h.table([h.AriaLabel(label), h.Class("diff")], [
35
35
  h.tbody([], Array.map(rows, (row) => __foldkitBrandViewResult((rowView(row, h)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#anonymous"))),
@@ -48,18 +48,22 @@ export const view = (config, h) => {
48
48
  /** A unified diff the caller already has (from a server, or `git diff -- <path>`) as `view`'s
49
49
  * table: its hunks' lines, numbered from their headers, and the unchanged lines between hunks
50
50
  * folded into a counted row. Lines past the last hunk are not in the patch, so nothing counts
51
- * them. An empty patch says there is no difference, as `view` does; anything but one file
52
- * with a hunk (text that is not a diff, a binary change, several files) says it cannot be
53
- * shown as lines. */
51
+ * them. What git's header says beyond the lines prints as a note line above the table, or in
52
+ * its place: a rename, a mode change, an empty or binary file (`describePatch` has the
53
+ * sentences). A file whose type changed (a file that became a symlink) is two sections, each
54
+ * its own table under one note, named for its side (`"<label>, as it was"`). An empty patch
55
+ * says there is no difference, as `view` does; text that is not a diff, or holds several
56
+ * files, says it cannot be shown as lines. */
54
57
  export const patchView = (config, h) => {
55
58
  const { patch, label } = config;
56
- if (String.isEmpty(String.trim(patch))) {
57
- return __foldkitBrandViewResult((noteView(SAME_NOTE, h)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#patchView");
58
- }
59
- else {
60
- return __foldkitBrandViewResult((Option.match(diffRowsOfPatch(patch), {
61
- onNone: () => __foldkitBrandViewResult((noteView("This diff cannot be shown as lines.", h)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#onNone"),
62
- onSome: (rows) => __foldkitBrandViewResult((tableView(rows, label, h)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#onSome"),
63
- })), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#patchView");
64
- }
59
+ const { notes, tables } = readPatch(patch);
60
+ const parts = [
61
+ ...(Array.isReadonlyArrayNonEmpty(notes) ? [noteView(Array.join(notes, " "), h)] : []),
62
+ ...Array.map(tables, ({ rows, maybeSide }) => __foldkitBrandViewResult((tableView(rows, Option.match(maybeSide, { onNone: () => __foldkitBrandViewResult((label), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#onNone"), onSome: (side) => __foldkitBrandViewResult((`${label}, ${side}`), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#onSome") }), h)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#anonymous~2")),
63
+ ];
64
+ // a lone table or note is drawn bare, so a flush window's body still meets its table
65
+ return __foldkitBrandViewResult((Array.match(parts, {
66
+ onEmpty: () => __foldkitBrandViewResult((noteView(SAME_NOTE, h)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#onEmpty"),
67
+ onNonEmpty: ([part, ...others]) => __foldkitBrandViewResult((Array.isArrayEmpty(others) ? part : h.div([h.Class("stack gap-s")], parts)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#onNonEmpty"),
68
+ })), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/lineDiff.js#patchView");
65
69
  };
@@ -0,0 +1,35 @@
1
+ import { Option } from "effect";
2
+ import { type DiffRow } from "./diff.ts";
3
+ /** The name a path in git's header stands for: git C-quotes a name with unusual bytes
4
+ * (`"caf\303\251.txt"`), and leaves any other as it is. */
5
+ export declare const unquoted: (path: string) => string;
6
+ /** What a diff of two same texts says. */
7
+ export declare const SAME_NOTE = "No difference: both sides hold the same text.";
8
+ /** A table of the patch's lines; `maybeSide` says which side of a type change it is. */
9
+ type Table = Readonly<{
10
+ rows: ReadonlyArray<DiffRow>;
11
+ maybeSide: Option.Option<string>;
12
+ }>;
13
+ /** What `patchView` draws: its notes as a line, then its tables. */
14
+ export type PatchReading = Readonly<{
15
+ maybeRenamedFrom: Option.Option<string>;
16
+ notes: ReadonlyArray<string>;
17
+ tables: ReadonlyArray<Table>;
18
+ }>;
19
+ /** What `patchView` makes of a patch: the notes it prints and the tables it draws. */
20
+ export declare const readPatch: (patch: string) => PatchReading;
21
+ /** What a patch says beyond its lines, as `patchView` prints it. */
22
+ export type PatchDescription = Readonly<{
23
+ /** The path a rename left, unquoted: for a heading such as `old.txt → new.txt`. */
24
+ maybeRenamedFrom: Option.Option<string>;
25
+ /** The sentences `patchView` prints above the patch's lines, or in their place: a rename,
26
+ * a mode change, a type change, an empty or binary file, no difference, or a patch it
27
+ * cannot show as lines. */
28
+ notes: ReadonlyArray<string>;
29
+ }>;
30
+ /** What `patchView` says about a patch from `git diff`, read from its header: the notes it
31
+ * prints (`["Renamed from old.txt."]`), for a caller that prints them itself, and the path a
32
+ * rename left. */
33
+ export declare const describePatch: (patch: string) => PatchDescription;
34
+ export {};
35
+ //# sourceMappingURL=patch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patch.d.ts","sourceRoot":"","sources":["../../../src/lineDiff/patch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAAgB,MAAM,QAAQ,CAAC;AAErD,OAAO,EAAE,KAAK,OAAO,EAAmB,MAAM,WAAW,CAAC;AAgC1D;4DAC4D;AAC5D,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MAO9B,CAAC;AAsCX,0CAA0C;AAC1C,eAAO,MAAM,SAAS,kDAAkD,CAAC;AAIzE,wFAAwF;AACxF,KAAK,KAAK,GAAG,QAAQ,CAAC;IAAE,IAAI,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC,CAAC;AAE1F,oEAAoE;AACpE,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC;IAClC,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACxC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC7B,MAAM,EAAE,aAAa,CAAC,KAAK,CAAC,CAAC;CAC9B,CAAC,CAAC;AAoFH,sFAAsF;AACtF,eAAO,MAAM,SAAS,UAAW,MAAM,KAAG,YAiBzC,CAAC;AAIF,oEAAoE;AACpE,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,mFAAmF;IACnF,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACxC;;gCAE4B;IAC5B,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC9B,CAAC,CAAC;AAEH;;mBAEmB;AACnB,eAAO,MAAM,aAAa,UAAW,MAAM,KAAG,gBAI7C,CAAC"}
@@ -0,0 +1,146 @@
1
+ import { brandViewResult as __foldkitBrandViewResult } from 'foldkit/brand'
2
+ import { Array, Option, String, pipe } from "effect";
3
+ import { diffRowsOfPatch } from "./diff.js";
4
+ // QUOTED PATHS
5
+ const ESCAPED_BYTES = {
6
+ a: 7,
7
+ b: 8,
8
+ t: 9,
9
+ n: 10,
10
+ v: 11,
11
+ f: 12,
12
+ r: 13,
13
+ };
14
+ // an octal escape is one byte of the name's UTF-8, so the bytes are gathered before decoding
15
+ const QUOTED_PART = /\\([0-7]{3})|\\(.)|[^\\]+/gu;
16
+ const bytesOf = (text) => __foldkitBrandViewResult(([...new TextEncoder().encode(text)]), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#bytesOf");
17
+ const bytesOfPart = ([part, octal, escaped]) => {
18
+ if (octal !== undefined) {
19
+ return __foldkitBrandViewResult(([Number.parseInt(octal, 8)]), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#bytesOfPart");
20
+ }
21
+ else if (escaped !== undefined) {
22
+ return __foldkitBrandViewResult((Option.match(Option.fromUndefinedOr(ESCAPED_BYTES[escaped]), {
23
+ onNone: () => __foldkitBrandViewResult((bytesOf(escaped)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#onNone"),
24
+ onSome: (byte) => __foldkitBrandViewResult(([byte]), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#onSome"),
25
+ })), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#bytesOfPart");
26
+ }
27
+ else {
28
+ return __foldkitBrandViewResult((bytesOf(part)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#bytesOfPart");
29
+ }
30
+ };
31
+ /** The name a path in git's header stands for: git C-quotes a name with unusual bytes
32
+ * (`"caf\303\251.txt"`), and leaves any other as it is. */
33
+ export const unquoted = (path) => __foldkitBrandViewResult((path.length >= 2 && path.startsWith('"') && path.endsWith('"')
34
+ ? new TextDecoder().decode(Uint8Array.from(Array.flatMap(Array.fromIterable(path.slice(1, -1).matchAll(QUOTED_PART)), bytesOfPart)))
35
+ : path), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#unquoted");
36
+ // HEADER
37
+ const GIT_SECTION = "diff --git ";
38
+ // the header is what comes before the first hunk, so a changed line that reads like a header
39
+ // line ("+rename from x") is never taken for one
40
+ const headOf = (section) => __foldkitBrandViewResult((Array.takeWhile(section.split("\n"), (line) => __foldkitBrandViewResult((!line.startsWith("@@") && !line.startsWith("--- ")), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#anonymous"))), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#headOf");
41
+ const valueAfter = (head, prefix) => __foldkitBrandViewResult((pipe(Array.findFirst(head, String.startsWith(prefix)), Option.map((line) => __foldkitBrandViewResult((line.slice(prefix.length)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#anonymous~2")))), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#valueAfter");
42
+ const hasLine = (head, prefix) => __foldkitBrandViewResult((Option.isSome(valueAfter(head, prefix))), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#hasLine");
43
+ const hasHunk = (section) => __foldkitBrandViewResult((Array.some(section.split("\n"), String.startsWith("@@ "))), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#hasHunk");
44
+ // git writes one section per file, after whatever precedes the first (a commit's message, a
45
+ // mail's head); a patch without a git header is one section
46
+ const sectionsOf = (patch) => {
47
+ const [first = "", ...others] = patch.split(/(?=^diff --git )/mu);
48
+ return __foldkitBrandViewResult((Array.isArrayNonEmpty(others) && !first.startsWith(GIT_SECTION)
49
+ ? others
50
+ : [first, ...others]), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#sectionsOf");
51
+ };
52
+ // READING
53
+ /** What a diff of two same texts says. */
54
+ export const SAME_NOTE = "No difference: both sides hold the same text.";
55
+ const UNREADABLE_NOTE = "This diff cannot be shown as lines.";
56
+ const BINARY_NOTE = "A binary file: its change has no lines.";
57
+ const unreadable = {
58
+ maybeRenamedFrom: Option.none(),
59
+ notes: [UNREADABLE_NOTE],
60
+ tables: [],
61
+ };
62
+ const lineNote = (head, hasRows) => {
63
+ if (hasLine(head, "Binary files ") || hasLine(head, "GIT binary patch")) {
64
+ return __foldkitBrandViewResult((Option.some(BINARY_NOTE)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#lineNote");
65
+ }
66
+ else if (hasRows) {
67
+ return __foldkitBrandViewResult((Option.none()), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#lineNote");
68
+ }
69
+ else if (hasLine(head, "new file mode ")) {
70
+ return __foldkitBrandViewResult((Option.some("New empty file.")), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#lineNote");
71
+ }
72
+ else if (hasLine(head, "deleted file mode ")) {
73
+ return __foldkitBrandViewResult((Option.some("Deleted empty file.")), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#lineNote");
74
+ }
75
+ else {
76
+ return __foldkitBrandViewResult((Option.none()), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#lineNote");
77
+ }
78
+ };
79
+ const readSection = (section) => {
80
+ const head = headOf(section);
81
+ const maybeRows = diffRowsOfPatch(section);
82
+ const maybeRenamedFrom = Option.map(valueAfter(head, "rename from "), unquoted);
83
+ const notes = [
84
+ ...Option.toArray(Option.map(maybeRenamedFrom, (from) => __foldkitBrandViewResult((`Renamed from ${from}.`), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#anonymous~3"))),
85
+ ...Option.toArray(Option.map(Option.all({ before: valueAfter(head, "old mode "), after: valueAfter(head, "new mode ") }), ({ before, after }) => __foldkitBrandViewResult((`Mode ${before} → ${after}.`), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#anonymous~4"))),
86
+ ...Option.toArray(lineNote(head, Option.isSome(maybeRows))),
87
+ ];
88
+ const isUnread = Option.isNone(maybeRows) && (hasHunk(section) || Array.isArrayEmpty(notes));
89
+ return __foldkitBrandViewResult((isUnread
90
+ ? unreadable
91
+ : {
92
+ maybeRenamedFrom,
93
+ notes,
94
+ tables: Option.toArray(Option.map(maybeRows, (rows) => (__foldkitBrandViewResult(({ rows, maybeSide: Option.none() }), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#anonymous~5")))),
95
+ }), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#readSection");
96
+ };
97
+ const KINDS = {
98
+ "100644": "a file",
99
+ "100755": "an executable file",
100
+ "120000": "a symlink",
101
+ "160000": "a submodule",
102
+ };
103
+ const kindOf = (mode) => __foldkitBrandViewResult((KINDS[mode] ?? `mode ${mode}`), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#kindOf");
104
+ const sideTable = (section, side) => __foldkitBrandViewResult((Option.toArray(Option.map(diffRowsOfPatch(section), (rows) => (__foldkitBrandViewResult(({ rows, maybeSide: Option.some(side) }), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#anonymous~6"))))), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#sideTable");
105
+ // a file that became a symlink (or any other kind) is two sections for one path: the old
106
+ // kind deleted, then the new one added
107
+ const readTypeChange = (gone, added) => {
108
+ const [goneHead, addedHead] = [headOf(gone), headOf(added)];
109
+ return __foldkitBrandViewResult((Option.flatMap(Option.all({
110
+ before: valueAfter(goneHead, "deleted file mode "),
111
+ after: valueAfter(addedHead, "new file mode "),
112
+ }), ({ before, after }) => __foldkitBrandViewResult((goneHead[0] === addedHead[0]
113
+ ? Option.some({
114
+ maybeRenamedFrom: Option.none(),
115
+ notes: [`It was ${kindOf(before)} and is now ${kindOf(after)}.`],
116
+ tables: [...sideTable(gone, "as it was"), ...sideTable(added, "as it is")],
117
+ })
118
+ : Option.none()), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#anonymous~7"))), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#readTypeChange");
119
+ };
120
+ /** What `patchView` makes of a patch: the notes it prints and the tables it draws. */
121
+ export const readPatch = (patch) => {
122
+ const text = patch.replaceAll("\r\n", "\n");
123
+ if (String.isEmpty(String.trim(text))) {
124
+ return __foldkitBrandViewResult(({ maybeRenamedFrom: Option.none(), notes: [SAME_NOTE], tables: [] }), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#readPatch");
125
+ }
126
+ else {
127
+ const sections = sectionsOf(text);
128
+ const [first, second, ...rest] = sections;
129
+ if (first !== undefined && second === undefined) {
130
+ return __foldkitBrandViewResult((readSection(first)), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#readPatch");
131
+ }
132
+ else if (first !== undefined && second !== undefined && Array.isArrayEmpty(rest)) {
133
+ return __foldkitBrandViewResult((Option.getOrElse(readTypeChange(first, second), () => __foldkitBrandViewResult((unreadable), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#anonymous~8"))), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#readPatch");
134
+ }
135
+ else {
136
+ return __foldkitBrandViewResult((unreadable), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#readPatch");
137
+ }
138
+ }
139
+ };
140
+ /** What `patchView` says about a patch from `git diff`, read from its header: the notes it
141
+ * prints (`["Renamed from old.txt."]`), for a caller that prints them itself, and the path a
142
+ * rename left. */
143
+ export const describePatch = (patch) => {
144
+ const { maybeRenamedFrom, notes } = readPatch(patch);
145
+ return __foldkitBrandViewResult(({ maybeRenamedFrom, notes }), "@gjermundgaraba-designs/pixel-paper/src/lineDiff/patch.js#describePatch");
146
+ };
@@ -76,6 +76,8 @@ export declare const update: (model: Model, message: Message) => Readonly<{
76
76
  };
77
77
  commands?: Update.Commands<{
78
78
  readonly _tag: "CompletedFocus";
79
+ } | {
80
+ readonly _tag: "PressedEscape";
79
81
  } | {
80
82
  readonly _tag: "ClickedToggle";
81
83
  } | {
@@ -84,8 +86,6 @@ export declare const update: (model: Model, message: Message) => Readonly<{
84
86
  readonly _tag: "CompletedPlaceSlip";
85
87
  } | {
86
88
  readonly _tag: "LeftSlip";
87
- } | {
88
- readonly _tag: "PressedEscape";
89
89
  } | {
90
90
  readonly _tag: "PressedTab";
91
91
  } | {
@@ -125,11 +125,11 @@ export declare const BindSlip: Mount.MountDefinitionWithArgs<"BindSlip", {
125
125
  isEnd: Schema.Boolean;
126
126
  edge: Schema.Literals<readonly ["First", "Last"]>;
127
127
  }, {
128
+ readonly _tag: "PressedEscape";
129
+ } | {
128
130
  readonly _tag: "CompletedPlaceSlip";
129
131
  } | {
130
132
  readonly _tag: "LeftSlip";
131
- } | {
132
- readonly _tag: "PressedEscape";
133
133
  } | {
134
134
  readonly _tag: "PressedTab";
135
135
  }>;
@@ -175,6 +175,8 @@ export declare const view: import("foldkit/submodel").View<{
175
175
  readonly edge: "First" | "Last";
176
176
  }, {
177
177
  readonly _tag: "CompletedFocus";
178
+ } | {
179
+ readonly _tag: "PressedEscape";
178
180
  } | {
179
181
  readonly _tag: "ClickedToggle";
180
182
  } | {
@@ -183,8 +185,6 @@ export declare const view: import("foldkit/submodel").View<{
183
185
  readonly _tag: "CompletedPlaceSlip";
184
186
  } | {
185
187
  readonly _tag: "LeftSlip";
186
- } | {
187
- readonly _tag: "PressedEscape";
188
188
  } | {
189
189
  readonly _tag: "PressedTab";
190
190
  } | {
@@ -245,8 +245,6 @@ export declare const update: (model: Model, message: Message) => Readonly<{
245
245
  }>;
246
246
  };
247
247
  commands?: Update.Commands<{
248
- readonly _tag: "PressedEscape";
249
- } | {
250
248
  readonly _tag: "CancelledPointer";
251
249
  } | {
252
250
  readonly _tag: "CompletedAnnounceReorder";
@@ -255,9 +253,6 @@ export declare const update: (model: Model, message: Message) => Readonly<{
255
253
  } | {
256
254
  readonly _tag: "GotDragAndDropMessage";
257
255
  readonly message: {
258
- readonly _tag: "PressedArrowKey";
259
- readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
260
- } | {
261
256
  readonly _tag: "ActivatedKeyboardDrag";
262
257
  readonly itemId: string;
263
258
  readonly containerId: string;
@@ -284,6 +279,9 @@ export declare const update: (model: Model, message: Message) => Readonly<{
284
279
  readonly containerId: string;
285
280
  readonly index: number;
286
281
  }>;
282
+ } | {
283
+ readonly _tag: "PressedArrowKey";
284
+ readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
287
285
  } | {
288
286
  readonly _tag: "PressedDraggable";
289
287
  readonly itemId: string;
@@ -301,6 +299,8 @@ export declare const update: (model: Model, message: Message) => Readonly<{
301
299
  readonly clientX: number;
302
300
  readonly clientY: number;
303
301
  readonly maybeIndex: Option.Option<number>;
302
+ } | {
303
+ readonly _tag: "PressedEscape";
304
304
  } | {
305
305
  readonly _tag: "PressedGripKey";
306
306
  readonly itemId: string;
@@ -413,8 +413,6 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
413
413
  }>;
414
414
  }>;
415
415
  }, {
416
- readonly _tag: "PressedEscape";
417
- } | {
418
416
  readonly _tag: "CancelledPointer";
419
417
  } | {
420
418
  readonly _tag: "CompletedAnnounceReorder";
@@ -423,9 +421,6 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
423
421
  } | {
424
422
  readonly _tag: "GotDragAndDropMessage";
425
423
  readonly message: {
426
- readonly _tag: "PressedArrowKey";
427
- readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
428
- } | {
429
424
  readonly _tag: "ActivatedKeyboardDrag";
430
425
  readonly itemId: string;
431
426
  readonly containerId: string;
@@ -452,6 +447,9 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
452
447
  readonly containerId: string;
453
448
  readonly index: number;
454
449
  }>;
450
+ } | {
451
+ readonly _tag: "PressedArrowKey";
452
+ readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
455
453
  } | {
456
454
  readonly _tag: "PressedDraggable";
457
455
  readonly itemId: string;
@@ -469,6 +467,8 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
469
467
  readonly clientX: number;
470
468
  readonly clientY: number;
471
469
  readonly maybeIndex: Option.Option<number>;
470
+ } | {
471
+ readonly _tag: "PressedEscape";
472
472
  } | {
473
473
  readonly _tag: "PressedGripKey";
474
474
  readonly itemId: string;
@@ -559,8 +559,6 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
559
559
  }>;
560
560
  }>;
561
561
  }, {
562
- readonly _tag: "PressedEscape";
563
- } | {
564
562
  readonly _tag: "CancelledPointer";
565
563
  } | {
566
564
  readonly _tag: "CompletedAnnounceReorder";
@@ -569,9 +567,6 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
569
567
  } | {
570
568
  readonly _tag: "GotDragAndDropMessage";
571
569
  readonly message: {
572
- readonly _tag: "PressedArrowKey";
573
- readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
574
- } | {
575
570
  readonly _tag: "ActivatedKeyboardDrag";
576
571
  readonly itemId: string;
577
572
  readonly containerId: string;
@@ -598,6 +593,9 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
598
593
  readonly containerId: string;
599
594
  readonly index: number;
600
595
  }>;
596
+ } | {
597
+ readonly _tag: "PressedArrowKey";
598
+ readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
601
599
  } | {
602
600
  readonly _tag: "PressedDraggable";
603
601
  readonly itemId: string;
@@ -615,6 +613,8 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
615
613
  readonly clientX: number;
616
614
  readonly clientY: number;
617
615
  readonly maybeIndex: Option.Option<number>;
616
+ } | {
617
+ readonly _tag: "PressedEscape";
618
618
  } | {
619
619
  readonly _tag: "PressedGripKey";
620
620
  readonly itemId: string;
@@ -710,8 +710,6 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
710
710
  }>;
711
711
  }>;
712
712
  }, {
713
- readonly _tag: "PressedEscape";
714
- } | {
715
713
  readonly _tag: "CancelledPointer";
716
714
  } | {
717
715
  readonly _tag: "CompletedAnnounceReorder";
@@ -720,9 +718,6 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
720
718
  } | {
721
719
  readonly _tag: "GotDragAndDropMessage";
722
720
  readonly message: {
723
- readonly _tag: "PressedArrowKey";
724
- readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
725
- } | {
726
721
  readonly _tag: "ActivatedKeyboardDrag";
727
722
  readonly itemId: string;
728
723
  readonly containerId: string;
@@ -749,6 +744,9 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
749
744
  readonly containerId: string;
750
745
  readonly index: number;
751
746
  }>;
747
+ } | {
748
+ readonly _tag: "PressedArrowKey";
749
+ readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
752
750
  } | {
753
751
  readonly _tag: "PressedDraggable";
754
752
  readonly itemId: string;
@@ -766,6 +764,8 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
766
764
  readonly clientX: number;
767
765
  readonly clientY: number;
768
766
  readonly maybeIndex: Option.Option<number>;
767
+ } | {
768
+ readonly _tag: "PressedEscape";
769
769
  } | {
770
770
  readonly _tag: "PressedGripKey";
771
771
  readonly itemId: string;
@@ -857,8 +857,6 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
857
857
  }>;
858
858
  }>;
859
859
  }, {
860
- readonly _tag: "PressedEscape";
861
- } | {
862
860
  readonly _tag: "CancelledPointer";
863
861
  } | {
864
862
  readonly _tag: "CompletedAnnounceReorder";
@@ -867,9 +865,6 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
867
865
  } | {
868
866
  readonly _tag: "GotDragAndDropMessage";
869
867
  readonly message: {
870
- readonly _tag: "PressedArrowKey";
871
- readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
872
- } | {
873
868
  readonly _tag: "ActivatedKeyboardDrag";
874
869
  readonly itemId: string;
875
870
  readonly containerId: string;
@@ -896,6 +891,9 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
896
891
  readonly containerId: string;
897
892
  readonly index: number;
898
893
  }>;
894
+ } | {
895
+ readonly _tag: "PressedArrowKey";
896
+ readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
899
897
  } | {
900
898
  readonly _tag: "PressedDraggable";
901
899
  readonly itemId: string;
@@ -913,6 +911,8 @@ export declare const subscriptions: <Prefix extends string>(keyPrefix: Prefix) =
913
911
  readonly clientX: number;
914
912
  readonly clientY: number;
915
913
  readonly maybeIndex: Option.Option<number>;
914
+ } | {
915
+ readonly _tag: "PressedEscape";
916
916
  } | {
917
917
  readonly _tag: "PressedGripKey";
918
918
  readonly itemId: string;
@@ -1055,8 +1055,6 @@ export declare const view: import("foldkit/submodel").View<{
1055
1055
  }>;
1056
1056
  }>;
1057
1057
  }, {
1058
- readonly _tag: "PressedEscape";
1059
- } | {
1060
1058
  readonly _tag: "CancelledPointer";
1061
1059
  } | {
1062
1060
  readonly _tag: "CompletedAnnounceReorder";
@@ -1065,9 +1063,6 @@ export declare const view: import("foldkit/submodel").View<{
1065
1063
  } | {
1066
1064
  readonly _tag: "GotDragAndDropMessage";
1067
1065
  readonly message: {
1068
- readonly _tag: "PressedArrowKey";
1069
- readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
1070
- } | {
1071
1066
  readonly _tag: "ActivatedKeyboardDrag";
1072
1067
  readonly itemId: string;
1073
1068
  readonly containerId: string;
@@ -1094,6 +1089,9 @@ export declare const view: import("foldkit/submodel").View<{
1094
1089
  readonly containerId: string;
1095
1090
  readonly index: number;
1096
1091
  }>;
1092
+ } | {
1093
+ readonly _tag: "PressedArrowKey";
1094
+ readonly direction: "Down" | "Left" | "NextContainer" | "PreviousContainer" | "Right" | "Up";
1097
1095
  } | {
1098
1096
  readonly _tag: "PressedDraggable";
1099
1097
  readonly itemId: string;
@@ -1111,6 +1109,8 @@ export declare const view: import("foldkit/submodel").View<{
1111
1109
  readonly clientX: number;
1112
1110
  readonly clientY: number;
1113
1111
  readonly maybeIndex: Option.Option<number>;
1112
+ } | {
1113
+ readonly _tag: "PressedEscape";
1114
1114
  } | {
1115
1115
  readonly _tag: "PressedGripKey";
1116
1116
  readonly itemId: string;
@@ -326,6 +326,8 @@ export declare const update: (model: Model, message: Message) => Readonly<{
326
326
  readonly pendingDismissVersion: number;
327
327
  readonly isHovered: boolean;
328
328
  readonly swipeState: {
329
+ readonly _tag: "Idle";
330
+ } | {
329
331
  readonly _tag: "Dismissing";
330
332
  readonly offsetX: number;
331
333
  readonly direction: "Left" | "Right";
@@ -334,8 +336,6 @@ export declare const update: (model: Model, message: Message) => Readonly<{
334
336
  readonly pointerId: number;
335
337
  readonly startX: number;
336
338
  readonly currentX: number;
337
- } | {
338
- readonly _tag: "Idle";
339
339
  } | {
340
340
  readonly _tag: "Settling";
341
341
  readonly offsetX: number;
@@ -371,15 +371,15 @@ export declare const update: (model: Model, message: Message) => Readonly<{
371
371
  readonly _tag: "GotAnimationMessage";
372
372
  readonly entryId: string;
373
373
  readonly message: {
374
+ readonly _tag: "Hid";
375
+ } | {
376
+ readonly _tag: "Showed";
377
+ } | {
374
378
  readonly _tag: "CompletedWaitForPaint";
375
379
  readonly generation: number;
376
380
  } | {
377
381
  readonly _tag: "EndedAnimation";
378
382
  readonly generation: number;
379
- } | {
380
- readonly _tag: "Hid";
381
- } | {
382
- readonly _tag: "Showed";
383
383
  };
384
384
  } | {
385
385
  readonly _tag: "HoveredEntry";
@@ -557,6 +557,8 @@ export declare const view: import("foldkit/submodel").View<{
557
557
  readonly pendingDismissVersion: number;
558
558
  readonly isHovered: boolean;
559
559
  readonly swipeState: {
560
+ readonly _tag: "Idle";
561
+ } | {
560
562
  readonly _tag: "Dismissing";
561
563
  readonly offsetX: number;
562
564
  readonly direction: "Left" | "Right";
@@ -565,8 +567,6 @@ export declare const view: import("foldkit/submodel").View<{
565
567
  readonly pointerId: number;
566
568
  readonly startX: number;
567
569
  readonly currentX: number;
568
- } | {
569
- readonly _tag: "Idle";
570
570
  } | {
571
571
  readonly _tag: "Settling";
572
572
  readonly offsetX: number;
@@ -602,15 +602,15 @@ export declare const view: import("foldkit/submodel").View<{
602
602
  readonly _tag: "GotAnimationMessage";
603
603
  readonly entryId: string;
604
604
  readonly message: {
605
+ readonly _tag: "Hid";
606
+ } | {
607
+ readonly _tag: "Showed";
608
+ } | {
605
609
  readonly _tag: "CompletedWaitForPaint";
606
610
  readonly generation: number;
607
611
  } | {
608
612
  readonly _tag: "EndedAnimation";
609
613
  readonly generation: number;
610
- } | {
611
- readonly _tag: "Hid";
612
- } | {
613
- readonly _tag: "Showed";
614
614
  };
615
615
  } | {
616
616
  readonly _tag: "HoveredEntry";
@@ -12,7 +12,9 @@ export type ViewConfig<Message> = Readonly<{
12
12
  glyph?: GlyphName;
13
13
  /** A chip in the title bar: its state. */
14
14
  status?: ReadonlyArray<Html | string>;
15
- /** IconButtons in the title bar, before the close. */
15
+ /** IconButtons in the title bar, before the close. A child Submodel, whose view is one
16
+ * `Html`, hands back several in a `span.window-tools`: it is no box of its own
17
+ * (`display: contents`), so its buttons sit in the bar as the others do. */
16
18
  tools?: ReadonlyArray<Html>;
17
19
  /** The close glyph's Message; the glyph prints when it is given. */
18
20
  onClose?: Message;
@@ -1 +1 @@
1
- {"version":3,"file":"window.d.ts","sourceRoot":"","sources":["../../../src/window/window.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAOpD,wDAAwD;AACxD,MAAM,MAAM,UAAU,CAAC,OAAO,IAAI,QAAQ,CAAC;IACzC,oFAAoF;IACpF,EAAE,EAAE,MAAM,CAAC;IACX,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,IAAI,CAAC,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC;IAC3B,+DAA+D;IAC/D,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,aAAa,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC;IACtC,sDAAsD;IACtD,KAAK,CAAC,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC;IAC5B,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,aAAa,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC;IACnC,iEAAiE;IACjE,IAAI,CAAC,EAAE,aAAa,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC;IACpC,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC,CAAC;AAEH;;6DAE6D;AAC7D,eAAO,MAAM,IAAI,GAAI,OAAO,UAAU,UAAU,CAAC,OAAO,CAAC,KAAK,WAAW,CAAC,OAAO,CAAC,KAAG,IAsCpF,CAAC"}
1
+ {"version":3,"file":"window.d.ts","sourceRoot":"","sources":["../../../src/window/window.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAOpD,wDAAwD;AACxD,MAAM,MAAM,UAAU,CAAC,OAAO,IAAI,QAAQ,CAAC;IACzC,oFAAoF;IACpF,EAAE,EAAE,MAAM,CAAC;IACX,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,IAAI,CAAC,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC;IAC3B,+DAA+D;IAC/D,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,aAAa,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC;IACtC;;iFAE6E;IAC7E,KAAK,CAAC,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC;IAC5B,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,aAAa,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC;IACnC,iEAAiE;IACjE,IAAI,CAAC,EAAE,aAAa,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC;IACpC,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC,CAAC;AAEH;;6DAE6D;AAC7D,eAAO,MAAM,IAAI,GAAI,OAAO,UAAU,UAAU,CAAC,OAAO,CAAC,KAAK,WAAW,CAAC,OAAO,CAAC,KAAG,IAsCpF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba-designs/pixel-paper",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,6 +48,9 @@ export type ViewConfig<Message> = Readonly<{
48
48
  isBoxed?: boolean;
49
49
  /** Code and identifiers: the system monospace. */
50
50
  isMono?: boolean;
51
+ /** The label is for screen readers only (`.sr-only`): a field whose place says what it is,
52
+ * such as a browser's address bar. It still names the input. */
53
+ isLabelHidden?: boolean;
51
54
  /** The input's type (`"date"` for the browser's date control, always boxed); text when not
52
55
  * given. */
53
56
  type?: string;
@@ -71,6 +74,7 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
71
74
  isRequired = false,
72
75
  isBoxed = false,
73
76
  isMono = false,
77
+ isLabelHidden = false,
74
78
  type,
75
79
  inputAttributes = [],
76
80
  } = config;
@@ -87,7 +91,7 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
87
91
  h.div(
88
92
  [h.Class(`field${isBoxed || type === "date" ? " boxed" : ""}${isMono ? " mono" : ""}`)],
89
93
  [
90
- h.label(groups.label, [label]),
94
+ h.label([...groups.label, ...(isLabelHidden ? [h.Class("sr-only")] : [])], [label]),
91
95
  // NOTE: @foldkit/ui gap: Input has no required option, so the wrapper says it.
92
96
  h.input([
93
97
  ...inputAttributes,
@@ -1,2 +1,4 @@
1
1
  export { patchView, view } from "./lineDiff.ts";
2
2
  export type { PatchViewConfig, ViewConfig } from "./lineDiff.ts";
3
+ export { describePatch } from "./patch.ts";
4
+ export type { PatchDescription } from "./patch.ts";
@@ -1,15 +1,9 @@
1
- import { Array, Option, String } from "effect";
1
+ import { Array, Option } from "effect";
2
2
  import type { Html, HtmlBuilder } from "foldkit/html";
3
3
 
4
4
  import { LOCALE } from "../locale/locale.ts";
5
- import {
6
- DiffRow,
7
- type LineKind,
8
- diffRowsOf,
9
- diffRowsOfPatch,
10
- foldUnchanged,
11
- hasChanges,
12
- } from "./diff.ts";
5
+ import { DiffRow, type LineKind, diffRowsOf, foldUnchanged, hasChanges } from "./diff.ts";
6
+ import { SAME_NOTE, readPatch } from "./patch.ts";
13
7
 
14
8
  // VIEW
15
9
 
@@ -29,7 +23,8 @@ export type ViewConfig = Readonly<{
29
23
 
30
24
  /** Configuration for rendering a unified diff with `patchView`. */
31
25
  export type PatchViewConfig = Readonly<{
32
- /** One file's unified diff, as `diff -u` or `git diff -- <path>` writes it. */
26
+ /** One file's unified diff, as `diff -u` or `git diff -- <path>` writes it, its git header
27
+ * included. */
33
28
  patch: string;
34
29
  /** Names the two sides for screen readers: `"Version 3 against version 4"`. */
35
30
  label: string;
@@ -86,8 +81,6 @@ const rowView = <Message>(row: DiffRow, h: HtmlBuilder<Message>): Html =>
86
81
  const noteView = <Message>(note: string, h: HtmlBuilder<Message>): Html =>
87
82
  h.p([h.Class("note-line")], [note]);
88
83
 
89
- const SAME_NOTE = "No difference: both sides hold the same text.";
90
-
91
84
  const tableView = <Message>(
92
85
  rows: ReadonlyArray<DiffRow>,
93
86
  label: string,
@@ -123,18 +116,30 @@ export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html
123
116
  /** A unified diff the caller already has (from a server, or `git diff -- <path>`) as `view`'s
124
117
  * table: its hunks' lines, numbered from their headers, and the unchanged lines between hunks
125
118
  * folded into a counted row. Lines past the last hunk are not in the patch, so nothing counts
126
- * them. An empty patch says there is no difference, as `view` does; anything but one file
127
- * with a hunk (text that is not a diff, a binary change, several files) says it cannot be
128
- * shown as lines. */
119
+ * them. What git's header says beyond the lines prints as a note line above the table, or in
120
+ * its place: a rename, a mode change, an empty or binary file (`describePatch` has the
121
+ * sentences). A file whose type changed (a file that became a symlink) is two sections, each
122
+ * its own table under one note, named for its side (`"<label>, as it was"`). An empty patch
123
+ * says there is no difference, as `view` does; text that is not a diff, or holds several
124
+ * files, says it cannot be shown as lines. */
129
125
  export const patchView = <Message>(config: PatchViewConfig, h: HtmlBuilder<Message>): Html => {
130
126
  const { patch, label } = config;
131
-
132
- if (String.isEmpty(String.trim(patch))) {
133
- return noteView(SAME_NOTE, h);
134
- } else {
135
- return Option.match(diffRowsOfPatch(patch), {
136
- onNone: () => noteView("This diff cannot be shown as lines.", h),
137
- onSome: (rows) => tableView(rows, label, h),
138
- });
139
- }
127
+ const { notes, tables } = readPatch(patch);
128
+ const parts = [
129
+ ...(Array.isReadonlyArrayNonEmpty(notes) ? [noteView(Array.join(notes, " "), h)] : []),
130
+ ...Array.map(tables, ({ rows, maybeSide }) =>
131
+ tableView(
132
+ rows,
133
+ Option.match(maybeSide, { onNone: () => label, onSome: (side) => `${label}, ${side}` }),
134
+ h,
135
+ ),
136
+ ),
137
+ ];
138
+
139
+ // a lone table or note is drawn bare, so a flush window's body still meets its table
140
+ return Array.match(parts, {
141
+ onEmpty: () => noteView(SAME_NOTE, h),
142
+ onNonEmpty: ([part, ...others]) =>
143
+ Array.isArrayEmpty(others) ? part : h.div([h.Class("stack gap-s")], parts),
144
+ });
140
145
  };
@@ -0,0 +1,218 @@
1
+ import { Array, Option, String, pipe } from "effect";
2
+
3
+ import { type DiffRow, diffRowsOfPatch } from "./diff.ts";
4
+
5
+ // QUOTED PATHS
6
+
7
+ const ESCAPED_BYTES: Readonly<Record<string, number>> = {
8
+ a: 7,
9
+ b: 8,
10
+ t: 9,
11
+ n: 10,
12
+ v: 11,
13
+ f: 12,
14
+ r: 13,
15
+ };
16
+
17
+ // an octal escape is one byte of the name's UTF-8, so the bytes are gathered before decoding
18
+ const QUOTED_PART = /\\([0-7]{3})|\\(.)|[^\\]+/gu;
19
+
20
+ const bytesOf = (text: string): ReadonlyArray<number> => [...new TextEncoder().encode(text)];
21
+
22
+ const bytesOfPart = ([part, octal, escaped]: RegExpExecArray): ReadonlyArray<number> => {
23
+ if (octal !== undefined) {
24
+ return [Number.parseInt(octal, 8)];
25
+ } else if (escaped !== undefined) {
26
+ return Option.match(Option.fromUndefinedOr(ESCAPED_BYTES[escaped]), {
27
+ onNone: () => bytesOf(escaped),
28
+ onSome: (byte) => [byte],
29
+ });
30
+ } else {
31
+ return bytesOf(part);
32
+ }
33
+ };
34
+
35
+ /** The name a path in git's header stands for: git C-quotes a name with unusual bytes
36
+ * (`"caf\303\251.txt"`), and leaves any other as it is. */
37
+ export const unquoted = (path: string): string =>
38
+ path.length >= 2 && path.startsWith('"') && path.endsWith('"')
39
+ ? new TextDecoder().decode(
40
+ Uint8Array.from(
41
+ Array.flatMap(Array.fromIterable(path.slice(1, -1).matchAll(QUOTED_PART)), bytesOfPart),
42
+ ),
43
+ )
44
+ : path;
45
+
46
+ // HEADER
47
+
48
+ const GIT_SECTION = "diff --git ";
49
+
50
+ // the header is what comes before the first hunk, so a changed line that reads like a header
51
+ // line ("+rename from x") is never taken for one
52
+ const headOf = (section: string): ReadonlyArray<string> =>
53
+ Array.takeWhile(
54
+ section.split("\n"),
55
+ (line) => !line.startsWith("@@") && !line.startsWith("--- "),
56
+ );
57
+
58
+ const valueAfter = (head: ReadonlyArray<string>, prefix: string): Option.Option<string> =>
59
+ pipe(
60
+ Array.findFirst(head, String.startsWith(prefix)),
61
+ Option.map((line) => line.slice(prefix.length)),
62
+ );
63
+
64
+ const hasLine = (head: ReadonlyArray<string>, prefix: string): boolean =>
65
+ Option.isSome(valueAfter(head, prefix));
66
+
67
+ const hasHunk = (section: string): boolean =>
68
+ Array.some(section.split("\n"), String.startsWith("@@ "));
69
+
70
+ // git writes one section per file, after whatever precedes the first (a commit's message, a
71
+ // mail's head); a patch without a git header is one section
72
+ const sectionsOf = (patch: string): ReadonlyArray<string> => {
73
+ const [first = "", ...others] = patch.split(/(?=^diff --git )/mu);
74
+
75
+ return Array.isArrayNonEmpty(others) && !first.startsWith(GIT_SECTION)
76
+ ? others
77
+ : [first, ...others];
78
+ };
79
+
80
+ // READING
81
+
82
+ /** What a diff of two same texts says. */
83
+ export const SAME_NOTE = "No difference: both sides hold the same text.";
84
+ const UNREADABLE_NOTE = "This diff cannot be shown as lines.";
85
+ const BINARY_NOTE = "A binary file: its change has no lines.";
86
+
87
+ /** A table of the patch's lines; `maybeSide` says which side of a type change it is. */
88
+ type Table = Readonly<{ rows: ReadonlyArray<DiffRow>; maybeSide: Option.Option<string> }>;
89
+
90
+ /** What `patchView` draws: its notes as a line, then its tables. */
91
+ export type PatchReading = Readonly<{
92
+ maybeRenamedFrom: Option.Option<string>;
93
+ notes: ReadonlyArray<string>;
94
+ tables: ReadonlyArray<Table>;
95
+ }>;
96
+
97
+ const unreadable: PatchReading = {
98
+ maybeRenamedFrom: Option.none(),
99
+ notes: [UNREADABLE_NOTE],
100
+ tables: [],
101
+ };
102
+
103
+ const lineNote = (head: ReadonlyArray<string>, hasRows: boolean): Option.Option<string> => {
104
+ if (hasLine(head, "Binary files ") || hasLine(head, "GIT binary patch")) {
105
+ return Option.some(BINARY_NOTE);
106
+ } else if (hasRows) {
107
+ return Option.none();
108
+ } else if (hasLine(head, "new file mode ")) {
109
+ return Option.some("New empty file.");
110
+ } else if (hasLine(head, "deleted file mode ")) {
111
+ return Option.some("Deleted empty file.");
112
+ } else {
113
+ return Option.none();
114
+ }
115
+ };
116
+
117
+ const readSection = (section: string): PatchReading => {
118
+ const head = headOf(section);
119
+ const maybeRows = diffRowsOfPatch(section);
120
+ const maybeRenamedFrom = Option.map(valueAfter(head, "rename from "), unquoted);
121
+ const notes = [
122
+ ...Option.toArray(Option.map(maybeRenamedFrom, (from) => `Renamed from ${from}.`)),
123
+ ...Option.toArray(
124
+ Option.map(
125
+ Option.all({ before: valueAfter(head, "old mode "), after: valueAfter(head, "new mode ") }),
126
+ ({ before, after }) => `Mode ${before} → ${after}.`,
127
+ ),
128
+ ),
129
+ ...Option.toArray(lineNote(head, Option.isSome(maybeRows))),
130
+ ];
131
+ const isUnread = Option.isNone(maybeRows) && (hasHunk(section) || Array.isArrayEmpty(notes));
132
+
133
+ return isUnread
134
+ ? unreadable
135
+ : {
136
+ maybeRenamedFrom,
137
+ notes,
138
+ tables: Option.toArray(
139
+ Option.map(maybeRows, (rows) => ({ rows, maybeSide: Option.none() })),
140
+ ),
141
+ };
142
+ };
143
+
144
+ const KINDS: Readonly<Record<string, string>> = {
145
+ "100644": "a file",
146
+ "100755": "an executable file",
147
+ "120000": "a symlink",
148
+ "160000": "a submodule",
149
+ };
150
+
151
+ const kindOf = (mode: string): string => KINDS[mode] ?? `mode ${mode}`;
152
+
153
+ const sideTable = (section: string, side: string): ReadonlyArray<Table> =>
154
+ Option.toArray(
155
+ Option.map(diffRowsOfPatch(section), (rows) => ({ rows, maybeSide: Option.some(side) })),
156
+ );
157
+
158
+ // a file that became a symlink (or any other kind) is two sections for one path: the old
159
+ // kind deleted, then the new one added
160
+ const readTypeChange = (gone: string, added: string): Option.Option<PatchReading> => {
161
+ const [goneHead, addedHead] = [headOf(gone), headOf(added)];
162
+
163
+ return Option.flatMap(
164
+ Option.all({
165
+ before: valueAfter(goneHead, "deleted file mode "),
166
+ after: valueAfter(addedHead, "new file mode "),
167
+ }),
168
+ ({ before, after }) =>
169
+ goneHead[0] === addedHead[0]
170
+ ? Option.some({
171
+ maybeRenamedFrom: Option.none(),
172
+ notes: [`It was ${kindOf(before)} and is now ${kindOf(after)}.`],
173
+ tables: [...sideTable(gone, "as it was"), ...sideTable(added, "as it is")],
174
+ })
175
+ : Option.none(),
176
+ );
177
+ };
178
+
179
+ /** What `patchView` makes of a patch: the notes it prints and the tables it draws. */
180
+ export const readPatch = (patch: string): PatchReading => {
181
+ const text = patch.replaceAll("\r\n", "\n");
182
+
183
+ if (String.isEmpty(String.trim(text))) {
184
+ return { maybeRenamedFrom: Option.none(), notes: [SAME_NOTE], tables: [] };
185
+ } else {
186
+ const sections = sectionsOf(text);
187
+ const [first, second, ...rest] = sections;
188
+
189
+ if (first !== undefined && second === undefined) {
190
+ return readSection(first);
191
+ } else if (first !== undefined && second !== undefined && Array.isArrayEmpty(rest)) {
192
+ return Option.getOrElse(readTypeChange(first, second), () => unreadable);
193
+ } else {
194
+ return unreadable;
195
+ }
196
+ }
197
+ };
198
+
199
+ // DESCRIPTION
200
+
201
+ /** What a patch says beyond its lines, as `patchView` prints it. */
202
+ export type PatchDescription = Readonly<{
203
+ /** The path a rename left, unquoted: for a heading such as `old.txt → new.txt`. */
204
+ maybeRenamedFrom: Option.Option<string>;
205
+ /** The sentences `patchView` prints above the patch's lines, or in their place: a rename,
206
+ * a mode change, a type change, an empty or binary file, no difference, or a patch it
207
+ * cannot show as lines. */
208
+ notes: ReadonlyArray<string>;
209
+ }>;
210
+
211
+ /** What `patchView` says about a patch from `git diff`, read from its header: the notes it
212
+ * prints (`["Renamed from old.txt."]`), for a caller that prints them itself, and the path a
213
+ * rename left. */
214
+ export const describePatch = (patch: string): PatchDescription => {
215
+ const { maybeRenamedFrom, notes } = readPatch(patch);
216
+
217
+ return { maybeRenamedFrom, notes };
218
+ };
@@ -5151,6 +5151,12 @@ dialog.veil:focus {
5151
5151
  color: inherit;
5152
5152
  }
5153
5153
 
5154
+ /* a child Submodel's tools come back as one element; the group is no box of its own, so its
5155
+ buttons take the bar's gap as the others do */
5156
+ .window-tools {
5157
+ display: contents;
5158
+ }
5159
+
5154
5160
  .window-body {
5155
5161
  flex: 1;
5156
5162
  min-height: 0;
@@ -20,7 +20,9 @@ export type ViewConfig<Message> = Readonly<{
20
20
  glyph?: GlyphName;
21
21
  /** A chip in the title bar: its state. */
22
22
  status?: ReadonlyArray<Html | string>;
23
- /** IconButtons in the title bar, before the close. */
23
+ /** IconButtons in the title bar, before the close. A child Submodel, whose view is one
24
+ * `Html`, hands back several in a `span.window-tools`: it is no box of its own
25
+ * (`display: contents`), so its buttons sit in the bar as the others do. */
24
26
  tools?: ReadonlyArray<Html>;
25
27
  /** The close glyph's Message; the glyph prints when it is given. */
26
28
  onClose?: Message;