@gjermundgaraba-designs/pixel-paper 0.20.0 → 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 +3 -3
- package/dist/src/commandPalette/commandPalette.d.ts +4 -3
- package/dist/src/commandPalette/commandPalette.d.ts.map +1 -1
- package/dist/src/commandPalette/commandPalette.js +34 -35
- package/dist/src/commandPalette/match.d.ts +5 -2
- package/dist/src/commandPalette/match.d.ts.map +1 -1
- package/dist/src/commandPalette/match.js +14 -3
- package/dist/src/field/field.d.ts +3 -0
- package/dist/src/field/field.d.ts.map +1 -1
- package/dist/src/field/field.js +2 -2
- package/dist/src/lineDiff/index.d.ts +2 -0
- package/dist/src/lineDiff/index.d.ts.map +1 -1
- package/dist/src/lineDiff/index.js +1 -0
- package/dist/src/lineDiff/lineDiff.d.ts +8 -4
- package/dist/src/lineDiff/lineDiff.d.ts.map +1 -1
- package/dist/src/lineDiff/lineDiff.js +19 -15
- package/dist/src/lineDiff/patch.d.ts +35 -0
- package/dist/src/lineDiff/patch.d.ts.map +1 -0
- package/dist/src/lineDiff/patch.js +146 -0
- package/dist/src/menu/menu.d.ts +6 -6
- package/dist/src/reel/layout.d.ts +2 -1
- package/dist/src/reel/layout.d.ts.map +1 -1
- package/dist/src/reel/layout.js +22 -4
- package/dist/src/sortable/sortable.d.ts +30 -30
- package/dist/src/toast/toast.d.ts +12 -12
- package/dist/src/window/window.d.ts +3 -1
- package/dist/src/window/window.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/commandPalette/commandPalette.ts +11 -15
- package/src/commandPalette/match.ts +22 -5
- package/src/field/field.ts +5 -1
- package/src/lineDiff/index.ts +2 -0
- package/src/lineDiff/lineDiff.ts +29 -24
- package/src/lineDiff/patch.ts +218 -0
- package/src/pixel-paper.css +6 -0
- package/src/reel/layout.ts +27 -4
- package/src/window/window.ts +3 -1
|
@@ -329,7 +329,7 @@ type ShownList = Readonly<{
|
|
|
329
329
|
shownShift: number;
|
|
330
330
|
}>;
|
|
331
331
|
|
|
332
|
-
const
|
|
332
|
+
const isListed = (rows: ReadonlyArray<Readonly<{ id: string }>>, rowId: string): boolean =>
|
|
333
333
|
Array.some(rows, (row) => row.id === rowId);
|
|
334
334
|
|
|
335
335
|
// NOTE: where the cursor is among the rows a key carries (see `Model`). With no arrows waiting
|
|
@@ -339,16 +339,16 @@ const isListedIn = (rows: ReadonlyArray<ShownRow>, rowId: string): boolean =>
|
|
|
339
339
|
// key waits for the next render.
|
|
340
340
|
const cursorAsShown = (model: Model, shown: ShownList): Option.Option<Option.Option<string>> => {
|
|
341
341
|
const { rows, maybeShownCursor, shownQuery, shownShift } = shown;
|
|
342
|
-
const
|
|
342
|
+
const isTheList = shownQuery === query(model);
|
|
343
343
|
|
|
344
|
-
if (
|
|
344
|
+
if (isTheList && model.cursorShift === 0) {
|
|
345
345
|
return Option.some(
|
|
346
346
|
Option.orElse(
|
|
347
|
-
Option.filter(maybeCurrentCursor(model), (rowId) =>
|
|
347
|
+
Option.filter(maybeCurrentCursor(model), (rowId) => isListed(rows, rowId)),
|
|
348
348
|
() => maybeShownCursor,
|
|
349
349
|
),
|
|
350
350
|
);
|
|
351
|
-
} else if (
|
|
351
|
+
} else if (isTheList && shownShift === model.cursorShift) {
|
|
352
352
|
return Option.some(maybeShownCursor);
|
|
353
353
|
} else {
|
|
354
354
|
return Option.none();
|
|
@@ -583,9 +583,6 @@ const pageOf = (commands: ReadonlyArray<Command>, state: OpenPage): Option.Optio
|
|
|
583
583
|
Option.map((page) => ({ commandId: state.commandId, page, state })),
|
|
584
584
|
);
|
|
585
585
|
|
|
586
|
-
const isListed = (rows: ReadonlyArray<Choice | Command>, rowId: string): boolean =>
|
|
587
|
-
Array.some(rows, (row) => row.id === rowId);
|
|
588
|
-
|
|
589
586
|
const firstListed = (
|
|
590
587
|
rows: ReadonlyArray<Choice>,
|
|
591
588
|
candidates: ReadonlyArray<Option.Option<string>>,
|
|
@@ -633,13 +630,11 @@ const unshiftedOf = (model: Model, commands: ReadonlyArray<Command>): Shown => {
|
|
|
633
630
|
};
|
|
634
631
|
},
|
|
635
632
|
onSome: ({ page, state }) => {
|
|
636
|
-
const { rows, found } = pageChoices(page, state.query);
|
|
637
|
-
const idsOf = (choices: ReadonlyArray<Choice>) =>
|
|
638
|
-
HashSet.fromIterable(Array.map(choices, ({ id }) => id));
|
|
633
|
+
const { rows, found, contextIds } = pageChoices(page, state.query);
|
|
639
634
|
return {
|
|
640
635
|
maybePage,
|
|
641
636
|
rows,
|
|
642
|
-
contextIds
|
|
637
|
+
contextIds,
|
|
643
638
|
maybeCursorId: pipe(
|
|
644
639
|
Option.filter(state.maybeCursor, (rowId) => isListed(rows, rowId)),
|
|
645
640
|
Option.orElse(() =>
|
|
@@ -657,9 +652,10 @@ const unshiftedOf = (model: Model, commands: ReadonlyArray<Command>): Shown => {
|
|
|
657
652
|
|
|
658
653
|
/** The row the cursor is on as the view shows it with `commands`: the row the reader moved it
|
|
659
654
|
* to while that row is listed, else the page's `start` or `current` while the query finds it,
|
|
660
|
-
* else the first row the query finds; none when nothing is listed. Compare it before and after
|
|
661
|
-
* when the cursor fell to another row (its row left) and
|
|
662
|
-
* `ScrollToCursor`; a cursor whose row is still listed keeps
|
|
655
|
+
* else the first row the query finds; none when nothing is listed. Compare it before and after
|
|
656
|
+
* the caller's commands change to know when the cursor fell to another row (its row left) and
|
|
657
|
+
* should be scrolled into view with `ScrollToCursor`; a cursor whose row is still listed keeps
|
|
658
|
+
* it, and the list its scroll. */
|
|
663
659
|
export const maybeCursorId = (
|
|
664
660
|
model: Model,
|
|
665
661
|
commands: ReadonlyArray<Command>,
|
|
@@ -78,9 +78,11 @@ export const findCommands = (
|
|
|
78
78
|
return Array.sort(ordered, Order.mapInput(Order.Number, groupIndex));
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
+
const isBlank = (query: string): boolean => String.isEmpty(String.trim(query));
|
|
82
|
+
|
|
81
83
|
/** The choices a page's query finds. */
|
|
82
84
|
export const findChoices = (page: Page, query: string): ReadonlyArray<Choice> =>
|
|
83
|
-
|
|
85
|
+
isBlank(query)
|
|
84
86
|
? page.choices
|
|
85
87
|
: Array.filter(page.choices, (choice) => matches(query, choice.label));
|
|
86
88
|
|
|
@@ -88,14 +90,22 @@ type Walk = Readonly<{ chain: ReadonlyArray<Choice>; keptIds: HashSet.HashSet<st
|
|
|
88
90
|
|
|
89
91
|
const WALK_START: Walk = { chain: [], keptIds: HashSet.empty() };
|
|
90
92
|
|
|
91
|
-
/** What a page shows for its query: the choices it finds
|
|
92
|
-
*
|
|
93
|
+
/** What a page shows for its query: the choices it finds; the rows, which are those choices
|
|
94
|
+
* each with the choices it sits under in the page's tree, in the page's order; and the rows
|
|
95
|
+
* kept only for the choices under them. */
|
|
93
96
|
export type PageChoices = Readonly<{
|
|
94
97
|
rows: ReadonlyArray<Choice>;
|
|
95
98
|
found: ReadonlyArray<Choice>;
|
|
99
|
+
contextIds: HashSet.HashSet<string>;
|
|
96
100
|
}>;
|
|
97
101
|
|
|
98
|
-
|
|
102
|
+
const unfiltered = (page: Page): PageChoices => ({
|
|
103
|
+
rows: page.choices,
|
|
104
|
+
found: page.choices,
|
|
105
|
+
contextIds: HashSet.empty(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const treeChoices = (page: Page, query: string): PageChoices => {
|
|
99
109
|
const found = findChoices(page, query);
|
|
100
110
|
const foundIds = HashSet.fromIterable(Array.map(found, ({ id }) => id));
|
|
101
111
|
const { keptIds } = Array.reduce(page.choices, WALK_START, ({ chain, keptIds }, choice): Walk => {
|
|
@@ -107,5 +117,12 @@ export const pageChoices = (page: Page, query: string): PageChoices => {
|
|
|
107
117
|
: keptIds,
|
|
108
118
|
};
|
|
109
119
|
});
|
|
110
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
rows: Array.filter(page.choices, ({ id }) => HashSet.has(keptIds, id)),
|
|
122
|
+
found,
|
|
123
|
+
contextIds: HashSet.difference(keptIds, foundIds),
|
|
124
|
+
};
|
|
111
125
|
};
|
|
126
|
+
|
|
127
|
+
export const pageChoices = (page: Page, query: string): PageChoices =>
|
|
128
|
+
isBlank(query) ? unfiltered(page) : treeChoices(page, query);
|
package/src/field/field.ts
CHANGED
|
@@ -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,
|
package/src/lineDiff/index.ts
CHANGED
package/src/lineDiff/lineDiff.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import { Array, Option
|
|
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
|
-
|
|
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.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
};
|
package/src/pixel-paper.css
CHANGED
|
@@ -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;
|
package/src/reel/layout.ts
CHANGED
|
@@ -111,10 +111,12 @@ export const columnOf = (layout: Layout, columnId: string): Option.Option<Column
|
|
|
111
111
|
const nextColumnId = (layout: Layout, columnIndex: number): Option.Option<string> =>
|
|
112
112
|
Option.map(Array.get(layout, columnIndex + 1), (column) => column.id);
|
|
113
113
|
|
|
114
|
+
// a running mean, which never passes the largest weight, where the weights' sum may overflow
|
|
114
115
|
const meanWeight = (panes: ReadonlyArray<Pane>): number =>
|
|
115
116
|
Array.match(panes, {
|
|
116
117
|
onEmpty: () => 1,
|
|
117
|
-
onNonEmpty: (some) =>
|
|
118
|
+
onNonEmpty: (some) =>
|
|
119
|
+
Array.reduce(some, 0, (mean, pane, index) => mean + (pane.weight - mean) / (index + 1)),
|
|
118
120
|
});
|
|
119
121
|
|
|
120
122
|
const othersIn = (column: Column, paneId: string): ReadonlyArray<Pane> =>
|
|
@@ -562,10 +564,21 @@ const PRECISION = 10_000;
|
|
|
562
564
|
|
|
563
565
|
const rounded = (value: number): number => Math.round(value * PRECISION) / PRECISION;
|
|
564
566
|
|
|
567
|
+
/** `weight`, at least `floor`, to four decimals, or, where a step of four decimals is more than
|
|
568
|
+
* `floor` (a column whose weights total under a five-hundredth, or two panes that small), to
|
|
569
|
+
* the first power of ten no more than it, so that it never rounds to nothing. A weight too
|
|
570
|
+
* large or a floor too small to scale by that power is left as it is. */
|
|
571
|
+
const roundedOver = (weight: number, floor: number): number => {
|
|
572
|
+
const precision = Math.max(PRECISION, 10 ** Math.ceil(-Math.log10(floor)));
|
|
573
|
+
const scaled = weight * precision;
|
|
574
|
+
return globalThis.Number.isFinite(scaled) ? Math.round(scaled) / precision : weight;
|
|
575
|
+
};
|
|
576
|
+
|
|
565
577
|
/** What moving `splitter` by `share` asks of the layout, from the sizes the layout holds (not
|
|
566
578
|
* the sizes printed, which the stylesheet may hold at a minimum): a width splitter adds
|
|
567
579
|
* `share` to its column's width; a pane splitter moves `share` of its column's height from
|
|
568
|
-
* the pane below it to the pane above
|
|
580
|
+
* the pane below it to the pane above, leaving each a weight above nothing however small the
|
|
581
|
+
* column. None when what it sizes is gone. */
|
|
569
582
|
export const resizeOf = (
|
|
570
583
|
layout: Layout,
|
|
571
584
|
splitter: Splitter,
|
|
@@ -584,14 +597,24 @@ export const resizeOf = (
|
|
|
584
597
|
Option.map(Array.get(column.panes, row + 1), (below) => {
|
|
585
598
|
const total = Number.sumAll(Array.map(column.panes, ({ weight }) => weight));
|
|
586
599
|
const pair = above.weight + below.weight;
|
|
587
|
-
|
|
600
|
+
// a column so small that a twentieth of it is no number keeps the least there is
|
|
601
|
+
const floor = Math.max(
|
|
602
|
+
Math.min(total * SHARE_FLOOR, pair / 2),
|
|
603
|
+
globalThis.Number.MIN_VALUE,
|
|
604
|
+
);
|
|
588
605
|
const nextAbove = Number.clamp(above.weight + share * total, {
|
|
589
606
|
minimum: floor,
|
|
590
607
|
maximum: pair - floor,
|
|
591
608
|
});
|
|
592
609
|
return OutMessage.ResizedPanes({
|
|
593
610
|
columnId: column.id,
|
|
594
|
-
weights:
|
|
611
|
+
weights: globalThis.Number.isFinite(total)
|
|
612
|
+
? {
|
|
613
|
+
[above.id]: roundedOver(nextAbove, floor),
|
|
614
|
+
[below.id]: roundedOver(pair - nextAbove, floor),
|
|
615
|
+
}
|
|
616
|
+
: // weights that total past the largest number have no share to move: they stay
|
|
617
|
+
{ [above.id]: above.weight, [below.id]: below.weight },
|
|
595
618
|
});
|
|
596
619
|
}),
|
|
597
620
|
),
|
package/src/window/window.ts
CHANGED
|
@@ -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;
|