@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79
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/package.json +14 -13
- package/src/App.diff.test.tsx +9 -4
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +75 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +6 -4
- package/src/App.test.tsx +31 -28
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +259 -274
- package/src/App.walk.test.tsx +9 -8
- package/src/App.workbench.test.tsx +86 -0
- package/src/annotation-palette.ts +1 -0
- package/src/app-screens.tsx +12 -6
- package/src/app-view-model.ts +0 -1
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/DiffContentView.tsx +14 -7
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +1 -1
- package/src/components/ThreadView.slash.test.tsx +0 -35
- package/src/components/ThreadView.tsx +142 -68
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories.test.tsx +4 -3
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.ts +0 -1
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +8 -6
- package/src/share.ts +15 -1
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +30 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +229 -54
- package/src/use-annotation-surface.tsx +45 -36
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -122
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -80
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
|
@@ -1,202 +1,79 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
expect(matchesLeader({ ctrl: true, name: "g" }, [DEFAULT_LEADER])).toBe(true);
|
|
15
|
-
expect(matchesLeader({ ctrl: true, name: "G" }, [DEFAULT_LEADER])).toBe(true);
|
|
16
|
-
expect(matchesLeader({ name: "g" }, [DEFAULT_LEADER])).toBe(false);
|
|
17
|
-
expect(matchesLeader({ ctrl: true, name: "x" }, [DEFAULT_LEADER])).toBe(false);
|
|
18
|
-
expect(matchesLeader({ ctrl: true, meta: true, name: "g" }, [DEFAULT_LEADER])).toBe(false);
|
|
19
|
-
expect(matchesLeader({ name: "," }, [","])).toBe(true);
|
|
2
|
+
import { resolveNavKey } from "./thread-chords";
|
|
3
|
+
|
|
4
|
+
const owner = { isOwner: true, resolved: false, treeActive: false };
|
|
5
|
+
const readOnly = { type: "status" as const, message: "observer - read-only" };
|
|
6
|
+
const submitted = { type: "status" as const, message: "review submitted - read-only" };
|
|
7
|
+
|
|
8
|
+
describe("resolveNavKey", () => {
|
|
9
|
+
test("the session commands: submit, edit, share", () => {
|
|
10
|
+
expect(resolveNavKey({ name: "return" }, owner)).toEqual({ type: "openSubmit" });
|
|
11
|
+
expect(resolveNavKey({ name: "enter" }, owner)).toEqual({ type: "openSubmit" });
|
|
12
|
+
expect(resolveNavKey({ name: "e" }, owner)).toEqual({ type: "edit" });
|
|
13
|
+
expect(resolveNavKey({ name: "s" }, owner)).toEqual({ type: "share" });
|
|
20
14
|
});
|
|
21
15
|
|
|
22
|
-
test("
|
|
23
|
-
expect(leaderHint(["ctrl+g"])).toBe("⌃g ");
|
|
24
|
-
expect(leaderHint([","])).toBe(", ");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("a key after the leader runs the same intent as its Option chord", () => {
|
|
16
|
+
test("a diff row's keys win over the shared letters, then fall through to curation", () => {
|
|
28
17
|
const diff = { ...owner, isDiff: true };
|
|
29
18
|
|
|
30
|
-
expect(
|
|
31
|
-
expect(
|
|
32
|
-
expect(
|
|
33
|
-
expect(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("resolveThreadChord", () => {
|
|
39
|
-
test("ctrl+enter opens the submit overlay for an owner with an open review", () => {
|
|
40
|
-
// Assert: the terminal-safe chord and the cmd forms all submit
|
|
41
|
-
expect(resolveThreadChord({ name: "return", ctrl: true }, owner)).toEqual({
|
|
42
|
-
type: "openSubmit",
|
|
43
|
-
});
|
|
44
|
-
expect(resolveThreadChord({ name: "return", super: true }, owner)).toEqual({
|
|
45
|
-
type: "openSubmit",
|
|
46
|
-
});
|
|
47
|
-
expect(resolveThreadChord({ name: "enter", meta: true }, owner)).toEqual({
|
|
48
|
-
type: "openSubmit",
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("session chords map to their intents", () => {
|
|
53
|
-
// Assert
|
|
54
|
-
expect(resolveThreadChord({ name: "e", ctrl: true }, owner)).toEqual({ type: "edit" });
|
|
55
|
-
expect(resolveThreadChord({ name: "s", ctrl: true }, owner)).toEqual({ type: "share" });
|
|
19
|
+
expect(resolveNavKey({ name: "x" }, diff)).toEqual({ type: "rejectChange" });
|
|
20
|
+
expect(resolveNavKey({ name: "c" }, diff)).toEqual({ type: "foldFile" });
|
|
21
|
+
expect(resolveNavKey({ name: "d" }, diff)).toEqual({ type: "toggleDiffView" });
|
|
22
|
+
expect(resolveNavKey({ name: "k" }, diff)).toEqual({ type: "walkStart" });
|
|
23
|
+
// a letter with no diff meaning still reaches the curation commands
|
|
24
|
+
expect(resolveNavKey({ name: "n" }, diff)).toEqual({ type: "nextAnnotation" });
|
|
56
25
|
});
|
|
57
26
|
|
|
58
|
-
test("
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
expect(
|
|
64
|
-
expect(
|
|
65
|
-
expect(resolveThreadChord({ name: "u", meta: true }, diff)).toEqual({
|
|
66
|
-
type: "restoreCuration",
|
|
67
|
-
});
|
|
68
|
-
expect(resolveThreadChord({ name: "c", meta: true }, diff)).toEqual({ type: "foldFile" });
|
|
69
|
-
expect(resolveThreadChord({ name: "d", meta: true }, diff)).toEqual({ type: "toggleDiffView" });
|
|
70
|
-
expect(resolveThreadChord({ name: "k", meta: true }, diff)).toEqual({ type: "walkStart" });
|
|
71
|
-
// the rail letters are untouched
|
|
72
|
-
expect(resolveThreadChord({ name: "n", meta: true }, diff)).toEqual({ type: "nextAnnotation" });
|
|
73
|
-
// rejecting is the owner's, and closed once resolved
|
|
74
|
-
expect(resolveThreadChord({ name: "x", meta: true }, { ...diff, isOwner: false })).toEqual({
|
|
75
|
-
type: "status",
|
|
76
|
-
message: "observer - read-only",
|
|
77
|
-
});
|
|
78
|
-
expect(resolveThreadChord({ name: "k", meta: true }, { ...diff, resolved: true })).toEqual({
|
|
79
|
-
type: "status",
|
|
80
|
-
message: "review submitted - read-only",
|
|
81
|
-
});
|
|
27
|
+
test("the discussion and curation commands", () => {
|
|
28
|
+
expect(resolveNavKey({ name: "n" }, owner)).toEqual({ type: "nextAnnotation" });
|
|
29
|
+
expect(resolveNavKey({ name: "p" }, owner)).toEqual({ type: "prevAnnotation" });
|
|
30
|
+
expect(resolveNavKey({ name: "backspace" }, owner)).toEqual({ type: "removeAnnotation" });
|
|
31
|
+
expect(resolveNavKey({ name: "r" }, owner)).toEqual({ type: "openRename" });
|
|
32
|
+
expect(resolveNavKey({ name: "x" }, owner)).toEqual({ type: "cut" });
|
|
33
|
+
expect(resolveNavKey({ name: "u" }, owner)).toEqual({ type: "restoreCuration" });
|
|
82
34
|
});
|
|
83
35
|
|
|
84
|
-
test("
|
|
85
|
-
|
|
86
|
-
expect(resolveThreadChord({ name: "n", meta: true }, owner)).toEqual({
|
|
87
|
-
type: "nextAnnotation",
|
|
88
|
-
});
|
|
89
|
-
expect(resolveThreadChord({ name: "p", meta: true }, owner)).toEqual({
|
|
90
|
-
type: "prevAnnotation",
|
|
91
|
-
});
|
|
92
|
-
expect(resolveThreadChord({ name: "e", meta: true }, owner)).toEqual({ type: "editCard" });
|
|
93
|
-
expect(resolveThreadChord({ name: "backspace", meta: true }, owner)).toEqual({
|
|
94
|
-
type: "removeAnnotation",
|
|
95
|
-
});
|
|
96
|
-
expect(resolveThreadChord({ name: "r", meta: true }, owner)).toEqual({ type: "openRename" });
|
|
97
|
-
expect(resolveThreadChord({ name: "x", meta: true }, owner)).toEqual({ type: "cut" });
|
|
98
|
-
expect(resolveThreadChord({ name: "u", meta: true }, owner)).toEqual({
|
|
99
|
-
type: "restoreCuration",
|
|
100
|
-
});
|
|
101
|
-
expect(resolveThreadChord({ name: "x", meta: true }, { ...owner, isOwner: false })).toEqual({
|
|
102
|
-
type: "status",
|
|
103
|
-
message: "observer - read-only",
|
|
104
|
-
});
|
|
105
|
-
});
|
|
36
|
+
test("the tree commands: move on the Tree tab, go, branch, label, fork, hand off", () => {
|
|
37
|
+
const onTree = { ...owner, treeActive: true };
|
|
106
38
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
expect(
|
|
110
|
-
|
|
111
|
-
).
|
|
112
|
-
expect(
|
|
39
|
+
expect(resolveNavKey({ name: "n" }, onTree)).toEqual({ type: "treeMove", direction: 1 });
|
|
40
|
+
expect(resolveNavKey({ name: "p" }, onTree)).toEqual({ type: "treeMove", direction: -1 });
|
|
41
|
+
expect(resolveNavKey({ name: "g" }, owner)).toEqual({ type: "treeGo" });
|
|
42
|
+
expect(resolveNavKey({ name: "b" }, owner)).toEqual({ type: "treeBranch" });
|
|
43
|
+
expect(resolveNavKey({ name: "l" }, owner)).toEqual({ type: "treeLabel" });
|
|
44
|
+
expect(resolveNavKey({ name: "f" }, owner)).toEqual({ type: "treeFork" });
|
|
45
|
+
expect(resolveNavKey({ name: "h" }, owner)).toEqual({ type: "treeForkShare" });
|
|
113
46
|
});
|
|
114
47
|
|
|
115
|
-
test("
|
|
116
|
-
|
|
117
|
-
expect(resolveThreadChord({ name: "return" }, owner)).toBeNull();
|
|
118
|
-
expect(resolveThreadChord({ name: "s" }, owner)).toBeNull();
|
|
119
|
-
expect(resolveThreadChord({ name: "x", ctrl: true }, owner)).toBeNull();
|
|
48
|
+
test("a shift letter resolves as its uppercase, which no command claims, so it returns to typing", () => {
|
|
49
|
+
expect(resolveNavKey({ name: "x", shift: true }, { ...owner, isDiff: true })).toBeNull();
|
|
120
50
|
});
|
|
121
51
|
|
|
122
|
-
test("a collaborator is
|
|
123
|
-
// Assert
|
|
52
|
+
test("a collaborator is refused the owner's commands; renaming stays open", () => {
|
|
124
53
|
const collaborator = { ...owner, isOwner: false };
|
|
125
|
-
const readOnly = { type: "status" as const, message: "observer - read-only" };
|
|
126
54
|
|
|
127
|
-
expect(
|
|
128
|
-
expect(
|
|
129
|
-
expect(
|
|
130
|
-
expect(
|
|
131
|
-
|
|
132
|
-
).
|
|
55
|
+
expect(resolveNavKey({ name: "return" }, collaborator)).toEqual(readOnly);
|
|
56
|
+
expect(resolveNavKey({ name: "e" }, collaborator)).toEqual(readOnly);
|
|
57
|
+
expect(resolveNavKey({ name: "s" }, collaborator)).toEqual(readOnly);
|
|
58
|
+
expect(resolveNavKey({ name: "x" }, collaborator)).toEqual(readOnly);
|
|
59
|
+
expect(resolveNavKey({ name: "g" }, collaborator)).toEqual(readOnly);
|
|
60
|
+
expect(resolveNavKey({ name: "f" }, collaborator)).toEqual(readOnly);
|
|
61
|
+
expect(resolveNavKey({ name: "r" }, collaborator)).toEqual({ type: "openRename" });
|
|
133
62
|
});
|
|
134
63
|
|
|
135
|
-
test("a resolved review refuses every change and says so;
|
|
136
|
-
// Arrange
|
|
64
|
+
test("a resolved review refuses every change and says so; a fork stays open", () => {
|
|
137
65
|
const resolved = { ...owner, resolved: true };
|
|
138
|
-
const submitted = { type: "status" as const, message: "review submitted - read-only" };
|
|
139
66
|
|
|
140
|
-
|
|
141
|
-
expect(
|
|
142
|
-
expect(
|
|
143
|
-
expect(
|
|
144
|
-
expect(
|
|
145
|
-
expect(
|
|
146
|
-
expect(resolveThreadChord({ name: "r", meta: true }, resolved)).toEqual({ type: "openRename" });
|
|
147
|
-
expect(resolveThreadChord({ name: "s", ctrl: true }, resolved)).toEqual({ type: "share" });
|
|
67
|
+
expect(resolveNavKey({ name: "return" }, resolved)).toBeNull();
|
|
68
|
+
expect(resolveNavKey({ name: "e" }, resolved)).toEqual(submitted);
|
|
69
|
+
expect(resolveNavKey({ name: "x" }, resolved)).toEqual(submitted);
|
|
70
|
+
expect(resolveNavKey({ name: "b" }, resolved)).toEqual(submitted);
|
|
71
|
+
expect(resolveNavKey({ name: "f" }, resolved)).toEqual({ type: "treeFork" });
|
|
72
|
+
expect(resolveNavKey({ name: "s" }, resolved)).toEqual({ type: "share" });
|
|
148
73
|
});
|
|
149
74
|
|
|
150
|
-
test("
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const readOnly = { type: "status" as const, message: "observer - read-only" };
|
|
154
|
-
const submitted = { type: "status" as const, message: "review submitted - read-only" };
|
|
155
|
-
|
|
156
|
-
// Assert: next / previous follow the tab that shows
|
|
157
|
-
expect(resolveThreadChord({ name: "t", meta: true }, owner)).toEqual({ type: "toggleTree" });
|
|
158
|
-
expect(resolveThreadChord({ name: "n", meta: true }, onTree)).toEqual({
|
|
159
|
-
type: "treeMove",
|
|
160
|
-
direction: 1,
|
|
161
|
-
});
|
|
162
|
-
expect(resolveThreadChord({ name: "p", meta: true }, onTree)).toEqual({
|
|
163
|
-
type: "treeMove",
|
|
164
|
-
direction: -1,
|
|
165
|
-
});
|
|
166
|
-
expect(resolveThreadChord({ name: "n", meta: true }, owner)).toEqual({
|
|
167
|
-
type: "nextAnnotation",
|
|
168
|
-
});
|
|
169
|
-
expect(resolveThreadChord({ name: "g", meta: true }, owner)).toEqual({ type: "treeGo" });
|
|
170
|
-
expect(resolveThreadChord({ name: "b", meta: true }, owner)).toEqual({ type: "treeBranch" });
|
|
171
|
-
expect(resolveThreadChord({ name: "l", meta: true }, owner)).toEqual({ type: "treeLabel" });
|
|
172
|
-
expect(resolveThreadChord({ name: "f", meta: true }, owner)).toEqual({ type: "treeFork" });
|
|
173
|
-
expect(resolveThreadChord({ name: "h", meta: true }, owner)).toEqual({ type: "treeForkShare" });
|
|
174
|
-
// moving the tree is the owner's; a fork is open after a verdict, a move is not
|
|
175
|
-
expect(resolveThreadChord({ name: "g", meta: true }, { ...owner, isOwner: false })).toEqual(
|
|
176
|
-
readOnly,
|
|
177
|
-
);
|
|
178
|
-
expect(resolveThreadChord({ name: "f", meta: true }, { ...owner, isOwner: false })).toEqual(
|
|
179
|
-
readOnly,
|
|
180
|
-
);
|
|
181
|
-
expect(resolveThreadChord({ name: "b", meta: true }, { ...owner, resolved: true })).toEqual(
|
|
182
|
-
submitted,
|
|
183
|
-
);
|
|
184
|
-
expect(resolveThreadChord({ name: "f", meta: true }, { ...owner, resolved: true })).toEqual({
|
|
185
|
-
type: "treeFork",
|
|
186
|
-
});
|
|
187
|
-
expect(resolveThreadChord({ name: "t", meta: true }, { ...owner, isOwner: false })).toEqual({
|
|
188
|
-
type: "toggleTree",
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("an observer cannot edit or delete cards either", () => {
|
|
193
|
-
// Arrange
|
|
194
|
-
const observer = { ...owner, isOwner: false };
|
|
195
|
-
const readOnly = { type: "status" as const, message: "observer - read-only" };
|
|
196
|
-
|
|
197
|
-
// Assert
|
|
198
|
-
expect(resolveThreadChord({ name: "e", meta: true }, observer)).toEqual(readOnly);
|
|
199
|
-
expect(resolveThreadChord({ name: "backspace", meta: true }, observer)).toEqual(readOnly);
|
|
200
|
-
expect(resolveThreadChord({ name: "r", meta: true }, observer)).toEqual({ type: "openRename" });
|
|
75
|
+
test("an unmapped key returns null, so the surface returns to typing", () => {
|
|
76
|
+
expect(resolveNavKey({ name: "z" }, owner)).toBeNull();
|
|
77
|
+
expect(resolveNavKey({ name: "j" }, owner)).toBeNull();
|
|
201
78
|
});
|
|
202
79
|
});
|
package/src/thread-chords.ts
CHANGED
|
@@ -1,186 +1,75 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The session-level chords of the thread view. Letters in the thread view
|
|
3
|
-
* type a comment, so everything that acts on the session as a whole - submit,
|
|
4
|
-
* share, edit, the rail - lives on ctrl chords (cmd where the terminal
|
|
5
|
-
* delivers it). The guided walk is a diff-review mode and has no chord here. Pure: the view reports whether a composer is open, this maps
|
|
6
|
-
* a key to an intent or to nothing.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
1
|
import type { Intent } from "./keymap";
|
|
10
2
|
|
|
11
|
-
export interface
|
|
3
|
+
export interface NavKey {
|
|
12
4
|
name: string;
|
|
13
|
-
|
|
14
|
-
meta?: boolean;
|
|
15
|
-
super?: boolean;
|
|
5
|
+
shift?: boolean;
|
|
16
6
|
}
|
|
17
7
|
|
|
18
|
-
export interface
|
|
19
|
-
/** A composer owns the keyboard: chords never fire while one is open. */
|
|
20
|
-
composing: boolean;
|
|
21
|
-
/** Collaborators annotate only; submit, edit, and share are the owner's. */
|
|
8
|
+
export interface ThreadNavContext {
|
|
22
9
|
isOwner: boolean;
|
|
23
|
-
/** A resolved session has nothing left to submit. */
|
|
24
10
|
resolved: boolean;
|
|
25
|
-
/** The rail shows the tree: next / previous move its selection instead of the cards. */
|
|
26
11
|
treeActive: boolean;
|
|
27
|
-
/** A diff review: the block chords act on the code row under the caret (reject, fold, walk). */
|
|
28
12
|
isDiff?: boolean;
|
|
29
13
|
}
|
|
30
14
|
|
|
31
|
-
export const DEFAULT_LEADER = "ctrl+g";
|
|
32
|
-
|
|
33
|
-
export function leaderCombosFor(configured: readonly string[] | undefined): readonly string[] {
|
|
34
|
-
return configured ?? [DEFAULT_LEADER];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const MOD_GLYPHS = new Map<string, string>([
|
|
38
|
-
["ctrl", "⌃"],
|
|
39
|
-
["control", "⌃"],
|
|
40
|
-
["meta", "⌥"],
|
|
41
|
-
["alt", "⌥"],
|
|
42
|
-
["option", "⌥"],
|
|
43
|
-
["shift", "⇧"],
|
|
44
|
-
["cmd", "⌘"],
|
|
45
|
-
["super", "⌘"],
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
function comboParts(combo: string) {
|
|
49
|
-
const parts = combo.split("+");
|
|
50
|
-
|
|
51
|
-
return { mods: parts.slice(0, -1).map((part) => part.toLowerCase()), name: parts.at(-1) ?? "" };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function leaderHint(combos: readonly string[]): string {
|
|
55
|
-
const { mods, name } = comboParts(combos[0] ?? DEFAULT_LEADER);
|
|
56
|
-
|
|
57
|
-
return `${mods.map((mod) => MOD_GLYPHS.get(mod) ?? mod).join("")}${name} `;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function matchesLeader(
|
|
61
|
-
key: { ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean; name: string },
|
|
62
|
-
combos: readonly string[],
|
|
63
|
-
): boolean {
|
|
64
|
-
return combos.some((combo) => {
|
|
65
|
-
const { mods, name } = comboParts(combo);
|
|
66
|
-
|
|
67
|
-
return (
|
|
68
|
-
key.name.toLowerCase() === name.toLowerCase() &&
|
|
69
|
-
Boolean(key.ctrl) === (mods.includes("ctrl") || mods.includes("control")) &&
|
|
70
|
-
Boolean(key.shift) === mods.includes("shift") &&
|
|
71
|
-
Boolean(key.super) === (mods.includes("cmd") || mods.includes("super")) &&
|
|
72
|
-
Boolean(key.meta) ===
|
|
73
|
-
(mods.includes("meta") || mods.includes("alt") || mods.includes("option"))
|
|
74
|
-
);
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
15
|
export interface ChordEntry {
|
|
79
16
|
keys: string;
|
|
80
17
|
label: string;
|
|
81
18
|
}
|
|
82
19
|
|
|
83
|
-
|
|
84
|
-
export function diffChordEntries(hint: string): ChordEntry[] {
|
|
20
|
+
export function sessionCommandEntries(): ChordEntry[] {
|
|
85
21
|
return [
|
|
86
|
-
{ keys:
|
|
87
|
-
{ keys:
|
|
88
|
-
{ keys:
|
|
89
|
-
{ keys: `${hint}d`, label: "split / stacked (when wide)" },
|
|
90
|
-
{ keys: `${hint}k`, label: "start the guided walk" },
|
|
22
|
+
{ keys: "⏎", label: "submit the review" },
|
|
23
|
+
{ keys: "e", label: "edit in $EDITOR" },
|
|
24
|
+
{ keys: "s", label: "share" },
|
|
91
25
|
];
|
|
92
26
|
}
|
|
93
27
|
|
|
94
|
-
|
|
95
|
-
export const THREAD_CHORD_ENTRIES = [
|
|
96
|
-
{ keys: "⌃enter", label: "submit the review" },
|
|
97
|
-
{ keys: "⌃e", label: "edit in $EDITOR" },
|
|
98
|
-
{ keys: "⌃s", label: "share" },
|
|
99
|
-
{ keys: "⌃r", label: "cycle the rail" },
|
|
100
|
-
] as const;
|
|
101
|
-
|
|
102
|
-
/** The rail and curation chords: the leader plus the plan sheet's old letter. */
|
|
103
|
-
export function railChordEntries(hint: string): ChordEntry[] {
|
|
28
|
+
export function diffCommandEntries(): ChordEntry[] {
|
|
104
29
|
return [
|
|
105
|
-
{ keys:
|
|
106
|
-
{ keys:
|
|
107
|
-
{ keys:
|
|
108
|
-
{ keys:
|
|
109
|
-
{ keys:
|
|
110
|
-
{ keys: `${hint}u`, label: "restore the last cut" },
|
|
111
|
-
{ keys: "⌥w / ⌥s", label: "widen / narrow the rail" },
|
|
30
|
+
{ keys: "x", label: "reject the change under the caret" },
|
|
31
|
+
{ keys: "u", label: "restore the last rejection" },
|
|
32
|
+
{ keys: "c", label: "collapse the file to its band" },
|
|
33
|
+
{ keys: "d", label: "split / stacked (when wide)" },
|
|
34
|
+
{ keys: "k", label: "start the guided walk" },
|
|
112
35
|
];
|
|
113
36
|
}
|
|
114
37
|
|
|
115
|
-
|
|
116
|
-
export function treeChordEntries(hint: string): ChordEntry[] {
|
|
38
|
+
export function curationCommandEntries(): ChordEntry[] {
|
|
117
39
|
return [
|
|
118
|
-
{ keys:
|
|
119
|
-
{ keys:
|
|
120
|
-
{ keys:
|
|
121
|
-
{ keys:
|
|
122
|
-
{ keys:
|
|
123
|
-
{ keys:
|
|
124
|
-
{ keys: `${hint}h`, label: "fork and hand off" },
|
|
40
|
+
{ keys: "n / p", label: "next / previous card" },
|
|
41
|
+
{ keys: "z", label: "fold / unfold the card" },
|
|
42
|
+
{ keys: "⌫", label: "delete the card" },
|
|
43
|
+
{ keys: "r", label: "rename the author" },
|
|
44
|
+
{ keys: "x", label: "cut the block" },
|
|
45
|
+
{ keys: "u", label: "restore the last cut" },
|
|
125
46
|
];
|
|
126
47
|
}
|
|
127
48
|
|
|
128
|
-
export function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (key.name === "return" || key.name === "enter") {
|
|
137
|
-
if (!context.isOwner) return READ_ONLY;
|
|
138
|
-
|
|
139
|
-
return context.resolved ? null : { type: "openSubmit" };
|
|
140
|
-
}
|
|
141
|
-
if (key.ctrl) return resolveSessionChord(key.name, context);
|
|
142
|
-
if (key.meta)
|
|
143
|
-
return (
|
|
144
|
-
(context.isDiff && resolveDiffChord(key.name, context)) || resolveRailChord(key.name, context)
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
return null;
|
|
49
|
+
export function treeCommandEntries(): ChordEntry[] {
|
|
50
|
+
return [
|
|
51
|
+
{ keys: "g", label: "go to the entry" },
|
|
52
|
+
{ keys: "b", label: "branch off the tip" },
|
|
53
|
+
{ keys: "l", label: "label a checkpoint" },
|
|
54
|
+
{ keys: "f", label: "fork the path" },
|
|
55
|
+
{ keys: "h", label: "fork and hand off" },
|
|
56
|
+
];
|
|
148
57
|
}
|
|
149
58
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
context: ThreadChordContext,
|
|
153
|
-
): Intent | null {
|
|
154
|
-
if (context.composing) return null;
|
|
155
|
-
if (key.name === "return" || key.name === "enter") {
|
|
156
|
-
if (!context.isOwner) return READ_ONLY;
|
|
157
|
-
|
|
158
|
-
return context.resolved ? null : { type: "openSubmit" };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return (
|
|
162
|
-
(context.isDiff && resolveDiffChord(key.name, context)) || resolveRailChord(key.name, context)
|
|
163
|
-
);
|
|
164
|
-
}
|
|
59
|
+
const READ_ONLY: Intent = { type: "status", message: "observer - read-only" };
|
|
60
|
+
const RESOLVED: Intent = { type: "status", message: "review submitted - read-only" };
|
|
165
61
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
context
|
|
169
|
-
dispatch: (intent: Intent) => void,
|
|
170
|
-
): void {
|
|
171
|
-
const name = key.shift ? key.name.toUpperCase() : key.name;
|
|
172
|
-
const chord = resolveLeaderCommand({ name }, context);
|
|
62
|
+
function mutating(intent: Intent, context: ThreadNavContext): Intent {
|
|
63
|
+
if (!context.isOwner) return READ_ONLY;
|
|
64
|
+
if (context.resolved) return RESOLVED;
|
|
173
65
|
|
|
174
|
-
|
|
66
|
+
return intent;
|
|
175
67
|
}
|
|
176
68
|
|
|
177
|
-
|
|
178
|
-
function resolveDiffChord(name: string, context: ThreadChordContext): Intent | null {
|
|
69
|
+
function resolveDiffKey(name: string, context: ThreadNavContext): Intent | null {
|
|
179
70
|
switch (name) {
|
|
180
71
|
case "x":
|
|
181
72
|
return mutating({ type: "rejectChange" }, context);
|
|
182
|
-
case "X":
|
|
183
|
-
return mutating({ type: "rejectHunk" }, context);
|
|
184
73
|
case "c":
|
|
185
74
|
return { type: "foldFile" };
|
|
186
75
|
case "d":
|
|
@@ -192,19 +81,7 @@ function resolveDiffChord(name: string, context: ThreadChordContext): Intent | n
|
|
|
192
81
|
}
|
|
193
82
|
}
|
|
194
83
|
|
|
195
|
-
|
|
196
|
-
const READ_ONLY: Intent = { type: "status", message: "observer - read-only" };
|
|
197
|
-
const RESOLVED: Intent = { type: "status", message: "review submitted - read-only" };
|
|
198
|
-
|
|
199
|
-
/** Editing, deleting, cutting, and restoring change the review: gated by role and by a verdict. */
|
|
200
|
-
function mutating(intent: Intent, context: ThreadChordContext): Intent {
|
|
201
|
-
if (!context.isOwner) return READ_ONLY;
|
|
202
|
-
if (context.resolved) return RESOLVED;
|
|
203
|
-
|
|
204
|
-
return intent;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function resolveSessionChord(name: string, context: ThreadChordContext): Intent | null {
|
|
84
|
+
function resolveSessionKey(name: string, context: ThreadNavContext): Intent | null {
|
|
208
85
|
switch (name) {
|
|
209
86
|
case "e":
|
|
210
87
|
return mutating({ type: "edit" }, context);
|
|
@@ -215,33 +92,24 @@ function resolveSessionChord(name: string, context: ThreadChordContext): Intent
|
|
|
215
92
|
}
|
|
216
93
|
}
|
|
217
94
|
|
|
218
|
-
|
|
219
|
-
// plan sheet's keymap so the rail keeps its muscle memory
|
|
220
|
-
function resolveRailChord(name: string, context: ThreadChordContext): Intent | null {
|
|
95
|
+
function resolveCurationKey(name: string, context: ThreadNavContext): Intent | null {
|
|
221
96
|
switch (name) {
|
|
222
97
|
case "n":
|
|
223
98
|
return context.treeActive ? { type: "treeMove", direction: 1 } : { type: "nextAnnotation" };
|
|
224
99
|
case "p":
|
|
225
100
|
return context.treeActive ? { type: "treeMove", direction: -1 } : { type: "prevAnnotation" };
|
|
226
|
-
case "t":
|
|
227
|
-
return { type: "toggleTree" };
|
|
228
|
-
// the tree is the owner's: a move, a branch, or a label changes what everyone sees
|
|
229
101
|
case "g":
|
|
230
102
|
return mutating({ type: "treeGo" }, context);
|
|
231
103
|
case "b":
|
|
232
104
|
return mutating({ type: "treeBranch" }, context);
|
|
233
105
|
case "l":
|
|
234
106
|
return mutating({ type: "treeLabel" }, context);
|
|
235
|
-
// a fork can be taken from a resolved review: only the role gates it
|
|
236
107
|
case "f":
|
|
237
108
|
return context.isOwner ? { type: "treeFork" } : READ_ONLY;
|
|
238
109
|
case "h":
|
|
239
110
|
return context.isOwner ? { type: "treeForkShare" } : READ_ONLY;
|
|
240
|
-
case "e":
|
|
241
|
-
return mutating({ type: "editCard" }, context);
|
|
242
111
|
case "backspace":
|
|
243
112
|
return mutating({ type: "removeAnnotation" }, context);
|
|
244
|
-
// renaming an author is a local display choice, open to every role
|
|
245
113
|
case "r":
|
|
246
114
|
return { type: "openRename" };
|
|
247
115
|
case "x":
|
|
@@ -252,3 +120,44 @@ function resolveRailChord(name: string, context: ThreadChordContext): Intent | n
|
|
|
252
120
|
return null;
|
|
253
121
|
}
|
|
254
122
|
}
|
|
123
|
+
|
|
124
|
+
export interface SessionChordKey {
|
|
125
|
+
name: string;
|
|
126
|
+
ctrl?: boolean;
|
|
127
|
+
meta?: boolean;
|
|
128
|
+
super?: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function resolveSessionChord(
|
|
132
|
+
key: SessionChordKey,
|
|
133
|
+
context: Pick<ThreadNavContext, "isOwner" | "resolved">,
|
|
134
|
+
): Intent | null {
|
|
135
|
+
if (key.name === "return" || key.name === "enter") {
|
|
136
|
+
if (!(key.ctrl || key.meta || key.super)) return null;
|
|
137
|
+
if (!context.isOwner) return READ_ONLY;
|
|
138
|
+
|
|
139
|
+
return context.resolved ? null : { type: "openSubmit" };
|
|
140
|
+
}
|
|
141
|
+
if (!key.ctrl) return null;
|
|
142
|
+
if (key.name === "e")
|
|
143
|
+
return context.resolved ? RESOLVED : context.isOwner ? { type: "edit" } : READ_ONLY;
|
|
144
|
+
if (key.name === "s") return context.isOwner ? { type: "share" } : READ_ONLY;
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolveNavKey(key: NavKey, context: ThreadNavContext): Intent | null {
|
|
150
|
+
const name = key.shift ? key.name.toUpperCase() : key.name;
|
|
151
|
+
|
|
152
|
+
if (name === "return" || name === "enter") {
|
|
153
|
+
if (!context.isOwner) return READ_ONLY;
|
|
154
|
+
|
|
155
|
+
return context.resolved ? null : { type: "openSubmit" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
(context.isDiff ? resolveDiffKey(name, context) : null) ??
|
|
160
|
+
resolveSessionKey(name, context) ??
|
|
161
|
+
resolveCurationKey(name, context)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -118,6 +118,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
|
|
|
118
118
|
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
119
119
|
sessionSetTitle: unimplemented("sessionSetTitle"),
|
|
120
120
|
sessionSetShareId: unimplemented("sessionSetShareId"),
|
|
121
|
+
sessionSetShares: unimplemented("sessionSetShares"),
|
|
121
122
|
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
122
123
|
sessionDelete: unimplemented("sessionDelete"),
|
|
123
124
|
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
@@ -38,7 +38,9 @@ const shareTransport: ShareTransport = {
|
|
|
38
38
|
pull: mock(async () => planSession()),
|
|
39
39
|
push: mock(async () => {}),
|
|
40
40
|
watch: () => () => {},
|
|
41
|
+
revoke: async () => {},
|
|
41
42
|
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
43
|
+
formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
|
|
42
44
|
collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
|
|
43
45
|
mergeFromShare,
|
|
44
46
|
};
|
|
@@ -82,6 +84,7 @@ function fakeClient(session: Thread): SessionClient {
|
|
|
82
84
|
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
83
85
|
sessionSetTitle: unimplemented("sessionSetTitle"),
|
|
84
86
|
sessionSetShareId: unimplemented("sessionSetShareId"),
|
|
87
|
+
sessionSetShares: unimplemented("sessionSetShares"),
|
|
85
88
|
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
86
89
|
sessionDelete: unimplemented("sessionDelete"),
|
|
87
90
|
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|