@tenyokjs/devdevian-cli 0.6.1-alpha.1 → 0.7.1-alpha.1
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/README.md +12 -2
- package/VERSIONS.md +17 -1
- package/bin/devdevian.mjs +5 -1
- package/lib/form-guidance.mjs +38 -0
- package/lib/palette.mjs +65 -0
- package/lib/tui-data.mjs +11 -2
- package/lib/tui-model.mjs +2 -1
- package/lib/tui.mjs +517 -74
- package/lib/workflow.mjs +91 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
|
|
|
38
38
|
- **N** creates a conversation (a community on the Communities screen); **A** asks a question; **R** replies inside a conversation; **Shift+R** refreshes.
|
|
39
39
|
- **C** comments on snippet lines; **E/D** edit/delete your own ordinary replies or code feedback; **S** resolves/reopens a code remark or accepts/removes a reply as the solution (question/review author only); **!** reports selected code feedback.
|
|
40
40
|
- **Ctrl+E** opens the current multiline field in `$VISUAL` / `$EDITOR` (use `code --wait` for VS Code). Closing the editor restores the form; publishing still requires preview and confirmation. **Ctrl+L** saves an active draft and returns to sign-in.
|
|
41
|
-
- In forms, **Tab/Shift+Tab** change fields, arrows choose options or move the text cursor, **Enter** adds a line, **Ctrl+S** opens the preview. Publication requires a separate confirmation; Escape asks before discarding a draft. Drafts auto-save locally after a short pause and can be restored from **Local drafts** or `/drafts`.
|
|
41
|
+
- In forms, **Tab/Shift+Tab** change fields or focus the step controls, arrows choose options or move the text cursor, **Enter** adds a line inside multiline text, and **Ctrl+S** opens the preview. Publication requires a separate confirmation; Escape asks before discarding a draft. Drafts auto-save locally after a short pause and can be restored from **Local drafts** or `/drafts`.
|
|
42
42
|
- **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
|
|
43
43
|
|
|
44
44
|
`devdevian open` launches Terminal on macOS, Windows Terminal on Windows, or GNOME Terminal/Konsole/xterm on a Linux desktop. A graphical desktop and an installed supported terminal are required; over SSH and inside an editor, use `devdevian tui`. Window launchers still need platform-specific user testing; terminal rendering and input are covered by macOS/Linux pseudo-terminal checks.
|
|
@@ -59,6 +59,16 @@ Calendar export from **X** writes a new `.ics` file inside the open workspace an
|
|
|
59
59
|
|
|
60
60
|
Forms autosave locally. Successful upload IDs are retained if validation fails; edits require the latest server revision. If a publication response is lost, the CLI preserves the draft and asks you to check My projects / Hosting or attending before trying another copy. It does not automatically retry an uncertain write. Upload quotas and public visibility are the same as the website. These catalog operations are currently interactive TUI features, not separate script subcommands.
|
|
61
61
|
|
|
62
|
+
## Quick actions and focused work
|
|
63
|
+
|
|
64
|
+
Press **Ctrl+K** outside a form or command input, or run **/actions**, to search actions, sections and creation flows. Recent views stay in memory for the current account/session; the palette offers up to eight. Type `project 7`, `event 9` or `open 142` for an explicit ID, or choose the forum-search result for a text query. Typing stays local; actions run only after Enter. Destructive actions retain their separate confirmation. **Esc** returns to the previous view without moving your selection.
|
|
65
|
+
|
|
66
|
+
Conversation, Project and Event forms show one step at a time. **Ctrl+N** validates the current step and moves forward; **Ctrl+P** returns to the previous step. **Tab / Shift+Tab** move through the current step and its **Back / Next** controls. Use arrows to choose a control and **Enter** to activate it; the last step offers **Preview**, not immediate publication. **Ctrl+S** checks every required field and opens the full publication preview; a missing value returns you to its step. Step changes preserve text, image paths, code selections and locally saved drafts. No upload or publication happens just by advancing a step.
|
|
67
|
+
|
|
68
|
+
In full-text reading (**Space** on a reply or remark), **/** opens local text search. **Enter** locates a matching rendered line; **n / Shift+N** cycles forward/backward. **Home / End** jumps to the start/end. **Esc** returns to the underlying view, and reopening the same unchanged item restores your place. This is bounded session memory, not cross-device or permanent reading history. In text fields and the command bar, **Ctrl+K** continues to delete to the end of the line.
|
|
69
|
+
|
|
70
|
+
Active fields explain their input: Space stays a space, Enter adds a line in multiline fields, and Tab leaves the field. Project feedback accepts text and URLs; dedicated website/repository/additional links are in **People & links**. Additional links use one `Label | https://example.com` per line. Context hints stay below the editor on larger terminals and use the status/hint lines on compact ones. **Ctrl+S** checks the complete form and opens a preview; drafts autosave locally and publication still requires explicit confirmation.
|
|
71
|
+
|
|
62
72
|
## Meet Devi
|
|
63
73
|
|
|
64
74
|
Devi is the Devdevian mascot: a small ghost with a laptop and an orbit. The terminal uses colored ASCII art, so it needs no image protocol or image downloads. A compact drawing is used when space is limited; authentication codes, addresses and controls take priority over decoration.
|
|
@@ -158,7 +168,7 @@ Commands are examples; replace IDs, file paths and topic IDs with your own. Run
|
|
|
158
168
|
|
|
159
169
|
A selected file is read only when explicitly named. Before publication, the client previews the payload and audience and asks for confirmation. Use `--community ID` for an approved private community, `--json` for machine-readable API output and `--yes` to explicitly publish without a prompt. Noninteractive publishing requires the relevant text flags and `--yes`. No repository discovery or background file collection runs.
|
|
160
170
|
|
|
161
|
-
`--editor` uses `$VISUAL`, then `$EDITOR`, then `vi` (`notepad` on Windows). The editor is launched without a shell. File, body and API limits also apply in the terminal. This client uses ordinary HTTPS API calls, not a direct database connection, and
|
|
171
|
+
`--editor` uses `$VISUAL`, then `$EDITOR`, then `vi` (`notepad` on Windows). The editor is launched without a shell. File, body and API limits also apply in the terminal. This client uses ordinary HTTPS API calls, not a direct database connection, and performs no background polling in script commands. The live workspace polls at bounded active/idle intervals unless paused or launched with `--no-live`. Device login polls every five seconds for at most ten minutes.
|
|
162
172
|
|
|
163
173
|
## Connected devices and credentials
|
|
164
174
|
|
package/VERSIONS.md
CHANGED
|
@@ -6,7 +6,23 @@ Every published npm artifact has a unique immutable version. We record each vers
|
|
|
6
6
|
|
|
7
7
|
## Current milestone
|
|
8
8
|
|
|
9
|
-
**0.
|
|
9
|
+
**0.7.0 focuses on everyday terminal use**: discoverable quick actions, staged forms and resumable reading.
|
|
10
|
+
|
|
11
|
+
## 0.7.1-alpha.1 — Clearer forms
|
|
12
|
+
|
|
13
|
+
- Added visible, keyboard-focusable Back / Next controls to staged forms. Tab reaches the controls at step boundaries, arrows choose an available action and Enter activates it. The final action is Preview; publication remains a separate confirmation. Ctrl+N/P shortcuts remain available.
|
|
14
|
+
- Pinned input guidance distinguishes Space, multiline Enter, field navigation and image/code pickers. Project help explicitly points to the dedicated People & links step. Compact layouts prioritize the caret and keep guidance in status/hint lines.
|
|
15
|
+
- Clarified additional-link syntax, preview versus autosaved drafts, and optional URL fields. Opening a new form clears stale success notices.
|
|
16
|
+
- Regression coverage publishes a project through the real terminal and isolated API, checks text spaces/newlines and multiple labeled links from a second account, exercises Back/Next/Preview, preserves Git/image selections and checks multiline editing at 60×18.
|
|
17
|
+
- No API/schema change, new dependency or production-data reset. Per-version history is recorded here; no separate GitHub release is required for this patch.
|
|
18
|
+
|
|
19
|
+
## 0.7.0-alpha.1 — Everyday flow
|
|
20
|
+
|
|
21
|
+
- Added a searchable quick-action workspace via `Ctrl+K` or `/actions`: scoped actions, navigation, creation, up to eight recent views, explicit ID opening and a regular forum-search fallback. Typing performs no requests or mutations; existing server permissions and confirmation dialogs still apply.
|
|
22
|
+
- Conversation, Project and Event forms show one step at a time. `Ctrl+N` validates the current step and advances; `Ctrl+P` goes back. `Tab` continues to traverse fields across steps. `Ctrl+S` validates the complete form and opens the existing explicit publication preview. All draft values and file selections survive step changes.
|
|
23
|
+
- Full reply/remark reading remembers positions within the current terminal session, restores the underlying view on close, and offers literal rendered-line search (`/`, `n`/`N`) plus Home/End and line-position indicators. Thread headings identify the parent reply and conversation. Reading memory is bounded and cleared on account reset.
|
|
24
|
+
- Quick actions pause live reads while open. Text editing retains `Ctrl+K` for deleting to the end of the line. Compact terminal layout, themes, external editor and draft workflows remain supported.
|
|
25
|
+
- No API/schema change, production-data reset or new dependency. Real pseudo-terminal checks cover staged publication into an isolated API visible to another browser account, reader search/return, scoped actions, and 60×18 navigation.
|
|
10
26
|
|
|
11
27
|
## 0.6.1-alpha.1 — A clearer first impression
|
|
12
28
|
|
package/bin/devdevian.mjs
CHANGED
|
@@ -115,7 +115,11 @@ const help = `Devdevian CLI ${version} · alpha
|
|
|
115
115
|
devdevian communities [--page 2]
|
|
116
116
|
|
|
117
117
|
Inside the workspace: /projects, /project ID, /events, /event ID
|
|
118
|
-
|
|
118
|
+
Ctrl+K or /actions opens quick actions; type to filter
|
|
119
|
+
Ctrl+N / Ctrl+P moves between form steps; Ctrl+S validates and previews
|
|
120
|
+
Forms: Tab fields/buttons, Enter selects Back/Next or adds a line in multiline text
|
|
121
|
+
Full reply text: / find, n/N matches, Home/End, Esc remembers your place
|
|
122
|
+
N creates · F filters · E edits your publication · X opens context actions
|
|
119
123
|
|
|
120
124
|
--theme NAME midnight | nebula | daylight | contrast
|
|
121
125
|
--accent NAME blue | violet | cyan | rose
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function fieldGuidance(field, { wizard = false, project = false } = {}) {
|
|
2
|
+
if (!field)
|
|
3
|
+
return {
|
|
4
|
+
detail: "",
|
|
5
|
+
keys: wizard
|
|
6
|
+
? "Enter selects the highlighted action"
|
|
7
|
+
: "Enter opens the preview",
|
|
8
|
+
};
|
|
9
|
+
let detail = field.hint || "";
|
|
10
|
+
if (project && field.key === "help")
|
|
11
|
+
detail =
|
|
12
|
+
"Text and links are allowed. Dedicated links: People & links step.";
|
|
13
|
+
if (["url", "repo", "demo_url"].includes(field.key) && !detail)
|
|
14
|
+
detail = "Use a complete https:// or http:// URL.";
|
|
15
|
+
if (["logo", "banner"].includes(field.key))
|
|
16
|
+
return { detail, keys: "Ctrl+O selects an image · Tab next field" };
|
|
17
|
+
if (field.key === "file")
|
|
18
|
+
return { detail, keys: "Ctrl+O file · Ctrl+G Git diff · Tab next field" };
|
|
19
|
+
const keys = field.options
|
|
20
|
+
? "← / → choose · Tab next field"
|
|
21
|
+
: field.multiline
|
|
22
|
+
? "Space inserts a space · Enter adds a line · Tab next field"
|
|
23
|
+
: "Space inserts a space · Enter / Tab next field";
|
|
24
|
+
return { detail, keys };
|
|
25
|
+
}
|
|
26
|
+
export function stepControls(steps, index) {
|
|
27
|
+
return [
|
|
28
|
+
{ id: "back", label: "← Back", enabled: index > 0 },
|
|
29
|
+
{
|
|
30
|
+
id: "next",
|
|
31
|
+
label:
|
|
32
|
+
index === steps.length - 1
|
|
33
|
+
? "Preview →"
|
|
34
|
+
: "Next: " + steps[index + 1]?.label + " →",
|
|
35
|
+
enabled: true,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
package/lib/palette.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { navigation } from "./tui-data.mjs";
|
|
2
|
+
import { clean } from "./client.mjs";
|
|
3
|
+
export function paletteItems({
|
|
4
|
+
query = "",
|
|
5
|
+
actions = [],
|
|
6
|
+
history = [],
|
|
7
|
+
route = {},
|
|
8
|
+
} = {}) {
|
|
9
|
+
const items = actions.map((action) => ({
|
|
10
|
+
...action,
|
|
11
|
+
id: "action:" + action.id,
|
|
12
|
+
group: "This view",
|
|
13
|
+
}));
|
|
14
|
+
navigation.forEach(([id, label, description], index) =>
|
|
15
|
+
items.push({ id: "nav:" + index, label, description, group: "Go to" }),
|
|
16
|
+
);
|
|
17
|
+
for (const [kind, label] of [
|
|
18
|
+
["discussion", "Start a discussion"],
|
|
19
|
+
["question", "Ask a question"],
|
|
20
|
+
["review", "Request a code review"],
|
|
21
|
+
["showcase", "Share what you built"],
|
|
22
|
+
["project", "Create a project"],
|
|
23
|
+
["event", "Create an event"],
|
|
24
|
+
])
|
|
25
|
+
items.push({ id: "create:" + kind, label, group: "Create" });
|
|
26
|
+
const seen = new Set([JSON.stringify(route)]);
|
|
27
|
+
[...history].reverse().forEach((item) => {
|
|
28
|
+
const key = JSON.stringify(item.route);
|
|
29
|
+
if (!seen.has(key) && item.route.view !== "help") {
|
|
30
|
+
seen.add(key);
|
|
31
|
+
if (seen.size <= 9)
|
|
32
|
+
items.push({
|
|
33
|
+
id: "recent:" + key,
|
|
34
|
+
label: clean(item.label || item.route.view),
|
|
35
|
+
group: "Recent",
|
|
36
|
+
route: item.route,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
const q = clean(query).trim().replace(/^\//, "");
|
|
41
|
+
const words = q.toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
42
|
+
const result = items.filter((item) =>
|
|
43
|
+
words.every((word) =>
|
|
44
|
+
`${item.label} ${item.description || ""} ${item.group}`
|
|
45
|
+
.toLocaleLowerCase()
|
|
46
|
+
.includes(word),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
const direct = q.match(/^(?:open|project|event)\s+([1-9]\d*)$/i);
|
|
50
|
+
if (direct && Number.isSafeInteger(Number(direct[1])))
|
|
51
|
+
result.unshift({
|
|
52
|
+
id: "command",
|
|
53
|
+
command: "/" + q.toLowerCase(),
|
|
54
|
+
label: "Open " + q,
|
|
55
|
+
group: "Command",
|
|
56
|
+
});
|
|
57
|
+
if (q)
|
|
58
|
+
result.push({
|
|
59
|
+
id: "search",
|
|
60
|
+
query: q,
|
|
61
|
+
label: "Search forum for “" + q + "”",
|
|
62
|
+
group: "Search",
|
|
63
|
+
});
|
|
64
|
+
return result;
|
|
65
|
+
}
|
package/lib/tui-data.mjs
CHANGED
|
@@ -183,7 +183,13 @@ export async function loadPage(api, route, signal) {
|
|
|
183
183
|
...replies.items,
|
|
184
184
|
],
|
|
185
185
|
title: "Reply thread",
|
|
186
|
-
subtitle:
|
|
186
|
+
subtitle:
|
|
187
|
+
"Reply #" +
|
|
188
|
+
route.parent.id +
|
|
189
|
+
" · " +
|
|
190
|
+
(parent?.name || route.parent.name) +
|
|
191
|
+
" · " +
|
|
192
|
+
post.title,
|
|
187
193
|
};
|
|
188
194
|
}
|
|
189
195
|
if (route.view === "feedback") {
|
|
@@ -220,6 +226,7 @@ export function contentLines(route, data) {
|
|
|
220
226
|
return [
|
|
221
227
|
"A developer workspace, in your terminal.",
|
|
222
228
|
"",
|
|
229
|
+
"Ctrl+K / /actions Search quick actions, sections and recent views",
|
|
223
230
|
"Tab Switch navigation / content / command bar",
|
|
224
231
|
"↑ ↓ Select a section, conversation or reply",
|
|
225
232
|
"Enter Open the selected item",
|
|
@@ -243,7 +250,9 @@ export function contentLines(route, data) {
|
|
|
243
250
|
"/ Focus the command / search bar",
|
|
244
251
|
"Ctrl+E Edit a multiline field in VISUAL / EDITOR",
|
|
245
252
|
"Ctrl+L Save a draft and reconnect an expired session",
|
|
246
|
-
"Ctrl+
|
|
253
|
+
"Ctrl+N / Ctrl+P Next / previous step in conversation/project/event forms",
|
|
254
|
+
"Ctrl+S Validate all steps and preview the current form",
|
|
255
|
+
"Full text / finds text; n/N cycles; Home/End; Esc remembers position",
|
|
247
256
|
"Ctrl+O / Ctrl+G Select a code file / Git diff in a review form",
|
|
248
257
|
"Ctrl+O Pick a workspace image on logo/banner/screenshot fields",
|
|
249
258
|
"/drafts Restore locally saved drafts for this account/project",
|
package/lib/tui-model.mjs
CHANGED
|
@@ -130,6 +130,7 @@ export function parseCommand(text) {
|
|
|
130
130
|
"devices",
|
|
131
131
|
"drafts",
|
|
132
132
|
"appearance",
|
|
133
|
+
"actions",
|
|
133
134
|
"projects",
|
|
134
135
|
"events",
|
|
135
136
|
].includes(cmd)
|
|
@@ -143,7 +144,7 @@ export function parseCommand(text) {
|
|
|
143
144
|
return { command: cmd, arg: Number(arg) };
|
|
144
145
|
if (["quit", "logout"].includes(cmd)) return { command: cmd };
|
|
145
146
|
throw Error(
|
|
146
|
-
"Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /drafts, /appearance, /help or /quit.",
|
|
147
|
+
"Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /drafts, /appearance, /actions, /projects, /events, /help or /quit.",
|
|
147
148
|
);
|
|
148
149
|
}
|
|
149
150
|
export class PageCache {
|
package/lib/tui.mjs
CHANGED
|
@@ -43,6 +43,15 @@ import {
|
|
|
43
43
|
appearanceFlags,
|
|
44
44
|
} from "./appearance.mjs";
|
|
45
45
|
import { liveLoop, anchoredIndex } from "./live.mjs";
|
|
46
|
+
import { fieldGuidance, stepControls } from "./form-guidance.mjs";
|
|
47
|
+
import { paletteItems } from "./palette.mjs";
|
|
48
|
+
import {
|
|
49
|
+
formSteps,
|
|
50
|
+
stepAt,
|
|
51
|
+
readingMatches,
|
|
52
|
+
nextReadingMatch,
|
|
53
|
+
ReadingMemory,
|
|
54
|
+
} from "./workflow.mjs";
|
|
46
55
|
import { contextActions } from "./context-actions.mjs";
|
|
47
56
|
import { editExternal } from "./external-editor.mjs";
|
|
48
57
|
import { markdownRows } from "./rich-text.mjs";
|
|
@@ -106,8 +115,13 @@ function openBrowser(url) {
|
|
|
106
115
|
child.unref();
|
|
107
116
|
}
|
|
108
117
|
function fieldsFor(form) {
|
|
109
|
-
if (["catalog", "catalogFilter", "calendar"].includes(form.kind))
|
|
110
|
-
|
|
118
|
+
if (["catalog", "catalogFilter", "calendar"].includes(form.kind)) {
|
|
119
|
+
const fields = catalogFields(form),
|
|
120
|
+
steps = formSteps(form, fields);
|
|
121
|
+
return steps.length
|
|
122
|
+
? steps.flatMap((step) => step.indexes.map((i) => fields[i]))
|
|
123
|
+
: fields;
|
|
124
|
+
}
|
|
111
125
|
if (form.kind === "appearance") return appearanceFields;
|
|
112
126
|
if (form.kind === "publication")
|
|
113
127
|
return [
|
|
@@ -276,17 +290,29 @@ function InputText({ value, cursor, width, height = 1 }) {
|
|
|
276
290
|
}),
|
|
277
291
|
);
|
|
278
292
|
}
|
|
279
|
-
function FormView({ form, active, cursor, width, height }) {
|
|
293
|
+
function FormView({ form, active, cursor, width, height, navFocus }) {
|
|
280
294
|
const colors = useTheme();
|
|
281
295
|
const fields = fieldsFor(form),
|
|
282
296
|
rows = [];
|
|
283
|
-
|
|
297
|
+
const steps = formSteps(form, fields),
|
|
298
|
+
stepIndex = stepAt(steps, active, fields.length),
|
|
299
|
+
step = steps[stepIndex];
|
|
300
|
+
const guidance = fieldGuidance(navFocus ? null : fields[active], {
|
|
301
|
+
wizard: !!step,
|
|
302
|
+
project: form.kind === "catalog" && form.entity === "project",
|
|
303
|
+
});
|
|
304
|
+
const helpRows =
|
|
305
|
+
form.kind === "appearance" ? 0 : height >= 8 ? 2 : height >= 5 ? 1 : 0,
|
|
306
|
+
navRows = step && height >= 3 ? 1 : 0;
|
|
307
|
+
const editorHeight = Math.max(1, height - helpRows - navRows);
|
|
308
|
+
let selectedLine = rows.length;
|
|
284
309
|
fields.forEach((field, index) => {
|
|
310
|
+
if (step && !step.indexes.includes(index)) return;
|
|
285
311
|
if (index === active) selectedLine = rows.length;
|
|
286
312
|
rows.push({
|
|
287
|
-
text: (index === active ? "› " : " ") + field.label,
|
|
288
|
-
color: index === active ? colors.purple : colors.muted,
|
|
289
|
-
bold: index === active,
|
|
313
|
+
text: (index === active && !navFocus ? "› " : " ") + field.label,
|
|
314
|
+
color: index === active && !navFocus ? colors.purple : colors.muted,
|
|
315
|
+
bold: index === active && !navFocus,
|
|
290
316
|
});
|
|
291
317
|
if (field.options) {
|
|
292
318
|
const option = field.options.find(
|
|
@@ -295,12 +321,12 @@ function FormView({ form, active, cursor, width, height }) {
|
|
|
295
321
|
rows.push({
|
|
296
322
|
text:
|
|
297
323
|
" ‹ " + (option?.[1] || form.values[field.key] || "Choose") + " ›",
|
|
298
|
-
color: index === active ? colors.blue : colors.text,
|
|
324
|
+
color: index === active && !navFocus ? colors.blue : colors.text,
|
|
299
325
|
});
|
|
300
326
|
} else {
|
|
301
327
|
const value = form.values[field.key] || "";
|
|
302
328
|
const text =
|
|
303
|
-
index === active
|
|
329
|
+
index === active && !navFocus
|
|
304
330
|
? cursorLines(value, cursor, width - 3).lines
|
|
305
331
|
: wrap(value || "…", width - 3);
|
|
306
332
|
const caretLine =
|
|
@@ -311,35 +337,90 @@ function FormView({ form, active, cursor, width, height }) {
|
|
|
311
337
|
)
|
|
312
338
|
: 0;
|
|
313
339
|
const visible = field.multiline
|
|
314
|
-
? Math.min(
|
|
340
|
+
? Math.min(
|
|
341
|
+
index === active
|
|
342
|
+
? Math.max(1, Math.min(12, editorHeight - 2))
|
|
343
|
+
: 3,
|
|
344
|
+
Math.max(2, text.length),
|
|
345
|
+
)
|
|
315
346
|
: 1,
|
|
316
347
|
start = index === active ? Math.max(0, caretLine - visible + 1) : 0;
|
|
317
348
|
rows.push(
|
|
318
349
|
...text.slice(start, start + visible).map((line) => ({
|
|
319
350
|
text: " " + line,
|
|
320
|
-
color: index === active ? colors.text : colors.muted,
|
|
351
|
+
color: index === active && !navFocus ? colors.text : colors.muted,
|
|
321
352
|
})),
|
|
322
353
|
);
|
|
323
354
|
}
|
|
324
|
-
if (field.hint && active === index)
|
|
325
|
-
rows.push({ text: " " + field.hint, color: colors.muted });
|
|
326
355
|
rows.push("");
|
|
327
356
|
});
|
|
328
357
|
if (active === fields.length) selectedLine = rows.length;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
358
|
+
if (!step)
|
|
359
|
+
rows.push({
|
|
360
|
+
text:
|
|
361
|
+
(active === fields.length ? "› " : " ") +
|
|
362
|
+
(form.kind === "appearance"
|
|
363
|
+
? "Save appearance [Ctrl+S]"
|
|
364
|
+
: form.kind === "catalogFilter"
|
|
365
|
+
? "Apply filters [Ctrl+S]"
|
|
366
|
+
: form.kind === "calendar"
|
|
367
|
+
? "Export calendar [Ctrl+S]"
|
|
368
|
+
: "Preview publication [Ctrl+S]"),
|
|
369
|
+
color: colors.blue,
|
|
370
|
+
bold: true,
|
|
371
|
+
});
|
|
338
372
|
const offset = Math.max(
|
|
339
373
|
0,
|
|
340
|
-
Math.min(
|
|
374
|
+
Math.min(
|
|
375
|
+
selectedLine - (editorHeight >= 4 ? 1 : 0),
|
|
376
|
+
rows.length - editorHeight,
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
return h(
|
|
380
|
+
Box,
|
|
381
|
+
{ flexDirection: "column", height, overflow: "hidden" },
|
|
382
|
+
h(FrameRows, { rows, width, height: editorHeight, offset }),
|
|
383
|
+
helpRows === 2
|
|
384
|
+
? h(TextLine, {
|
|
385
|
+
text:
|
|
386
|
+
guidance.detail ||
|
|
387
|
+
(form.kind === "catalogFilter"
|
|
388
|
+
? "Ctrl+S applies these filters."
|
|
389
|
+
: form.kind === "calendar"
|
|
390
|
+
? "Ctrl+S exports a new calendar file."
|
|
391
|
+
: "Ctrl+S opens a preview; publishing requires separate confirmation."),
|
|
392
|
+
width,
|
|
393
|
+
color: colors.muted,
|
|
394
|
+
})
|
|
395
|
+
: null,
|
|
396
|
+
helpRows
|
|
397
|
+
? h(TextLine, { text: guidance.keys, width, color: colors.muted })
|
|
398
|
+
: null,
|
|
399
|
+
navRows
|
|
400
|
+
? h(
|
|
401
|
+
Text,
|
|
402
|
+
{ wrap: "truncate" },
|
|
403
|
+
...stepControls(steps, stepIndex).map((control) =>
|
|
404
|
+
h(
|
|
405
|
+
Text,
|
|
406
|
+
{
|
|
407
|
+
key: control.id,
|
|
408
|
+
color: !control.enabled
|
|
409
|
+
? colors.muted
|
|
410
|
+
: navFocus === control.id
|
|
411
|
+
? colors.blue
|
|
412
|
+
: colors.purple,
|
|
413
|
+
bold: navFocus === control.id,
|
|
414
|
+
dimColor: !control.enabled,
|
|
415
|
+
backgroundColor:
|
|
416
|
+
navFocus === control.id ? colors.selected : undefined,
|
|
417
|
+
},
|
|
418
|
+
` ${navFocus === control.id ? "›" : " "}[ ${control.label} ] `,
|
|
419
|
+
),
|
|
420
|
+
),
|
|
421
|
+
)
|
|
422
|
+
: null,
|
|
341
423
|
);
|
|
342
|
-
return h(FrameRows, { rows, width, height, offset });
|
|
343
424
|
}
|
|
344
425
|
function listRows(route, data, index, width, colors, density = "comfortable") {
|
|
345
426
|
const items = data.items || [];
|
|
@@ -566,6 +647,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
566
647
|
[notice, setNotice] = useState(initialAppearance.warning || "");
|
|
567
648
|
const [form, setForm] = useState(null),
|
|
568
649
|
[field, setField] = useState(0),
|
|
650
|
+
[formNav, setFormNav] = useState(null),
|
|
569
651
|
[cursor, setCursor] = useState(0),
|
|
570
652
|
[preview, setPreview] = useState(null),
|
|
571
653
|
[dialog, setDialog] = useState(null),
|
|
@@ -576,6 +658,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
576
658
|
[picker, setPicker] = useState(null),
|
|
577
659
|
[draftSaved, setDraftSaved] = useState(false),
|
|
578
660
|
[actionIndex, setActionIndex] = useState(null),
|
|
661
|
+
[palette, setPalette] = useState(null),
|
|
662
|
+
[readerFind, setReaderFind] = useState({
|
|
663
|
+
query: "",
|
|
664
|
+
cursor: 0,
|
|
665
|
+
editing: false,
|
|
666
|
+
}),
|
|
579
667
|
[reviewFocus, setReviewFocus] = useState("notes"),
|
|
580
668
|
[reviewLine, setReviewLine] = useState(0),
|
|
581
669
|
[reviewAnchor, setReviewAnchor] = useState(null),
|
|
@@ -583,6 +671,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
583
671
|
[live, setLive] = useState(!flags["no-live"]),
|
|
584
672
|
[unread, setUnread] = useState(0),
|
|
585
673
|
[liveStatus, setLiveStatus] = useState("");
|
|
674
|
+
const readingMemory = useRef(new ReadingMemory()),
|
|
675
|
+
readingReturn = useRef(0);
|
|
586
676
|
const activity = useRef(Date.now()),
|
|
587
677
|
liveView = useRef(null),
|
|
588
678
|
notificationGeneration = useRef(0);
|
|
@@ -609,7 +699,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
609
699
|
);
|
|
610
700
|
const effectiveAppearance =
|
|
611
701
|
form?.kind === "appearance" ? normalizeAppearance(form.values) : appearance;
|
|
612
|
-
const colors =
|
|
702
|
+
const colors = useMemo(
|
|
703
|
+
() => themeFor(effectiveAppearance),
|
|
704
|
+
[effectiveAppearance],
|
|
705
|
+
),
|
|
613
706
|
stride = effectiveAppearance.density === "compact" ? 3 : 4;
|
|
614
707
|
const width = Math.max(1, columns),
|
|
615
708
|
height = Math.max(1, rows - 1),
|
|
@@ -644,6 +737,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
644
737
|
dialog ||
|
|
645
738
|
reading ||
|
|
646
739
|
actionIndex !== null ||
|
|
740
|
+
palette ||
|
|
647
741
|
loading ||
|
|
648
742
|
pending ||
|
|
649
743
|
focus === "prompt"
|
|
@@ -715,6 +809,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
715
809
|
const resetAccount = () => {
|
|
716
810
|
cache.current.clear();
|
|
717
811
|
history.current = [];
|
|
812
|
+
readingMemory.current.clear();
|
|
813
|
+
setPalette(null);
|
|
814
|
+
setReaderFind({ query: "", cursor: 0, editing: false });
|
|
718
815
|
setData(null);
|
|
719
816
|
setForm(null);
|
|
720
817
|
setPicker(null);
|
|
@@ -749,6 +846,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
749
846
|
if (!replace)
|
|
750
847
|
history.current.push({
|
|
751
848
|
route,
|
|
849
|
+
label: current?.title || labelOf(route),
|
|
752
850
|
index,
|
|
753
851
|
scroll,
|
|
754
852
|
selectedId: selected?.id,
|
|
@@ -799,6 +897,124 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
799
897
|
setError("");
|
|
800
898
|
} else setFocus("nav");
|
|
801
899
|
}
|
|
900
|
+
function readerKey(item) {
|
|
901
|
+
const kind =
|
|
902
|
+
item.entryType === "feedbackReply"
|
|
903
|
+
? "feedbackReply"
|
|
904
|
+
: item.entryType === "note" ||
|
|
905
|
+
route.view === "feedback" ||
|
|
906
|
+
route.tab === "feedback"
|
|
907
|
+
? "feedback"
|
|
908
|
+
: "reply";
|
|
909
|
+
return `${config?.origin}:${user?.id}:${current?.post?.id}:${kind}:${item.id}:${item.revision || ""}`;
|
|
910
|
+
}
|
|
911
|
+
const readerRows = useMemo(
|
|
912
|
+
() =>
|
|
913
|
+
reading
|
|
914
|
+
? markdownRows(
|
|
915
|
+
reading.deleted_at
|
|
916
|
+
? "[deleted — discussion preserved]"
|
|
917
|
+
: reading.body || "",
|
|
918
|
+
contentWidth,
|
|
919
|
+
colors,
|
|
920
|
+
)
|
|
921
|
+
: [],
|
|
922
|
+
[reading, contentWidth, colors],
|
|
923
|
+
);
|
|
924
|
+
const readerHeight = Math.max(1, contentHeight - 2);
|
|
925
|
+
const readerHits = readingMatches(readerRows, readerFind.query);
|
|
926
|
+
useEffect(() => {
|
|
927
|
+
if (reading)
|
|
928
|
+
readingMemory.current.save(
|
|
929
|
+
readerKey(reading),
|
|
930
|
+
Math.min(scroll, Math.max(0, readerRows.length - readerHeight)),
|
|
931
|
+
readerRows,
|
|
932
|
+
);
|
|
933
|
+
}, [reading, scroll, readerRows, readerHeight]);
|
|
934
|
+
function readItem(item) {
|
|
935
|
+
if (!reading) readingReturn.current = scroll;
|
|
936
|
+
const lines = markdownRows(
|
|
937
|
+
item.deleted_at ? "[deleted — discussion preserved]" : item.body || "",
|
|
938
|
+
contentWidth,
|
|
939
|
+
colors,
|
|
940
|
+
);
|
|
941
|
+
setScroll(
|
|
942
|
+
readingMemory.current.restore(readerKey(item), lines, readerHeight),
|
|
943
|
+
);
|
|
944
|
+
setReaderFind({ query: "", cursor: 0, editing: false });
|
|
945
|
+
setReading(item);
|
|
946
|
+
}
|
|
947
|
+
function closeReading() {
|
|
948
|
+
setReading(null);
|
|
949
|
+
setScroll(readingReturn.current);
|
|
950
|
+
setReaderFind({ query: "", cursor: 0, editing: false });
|
|
951
|
+
}
|
|
952
|
+
function moveStep(direction) {
|
|
953
|
+
setFormNav(null);
|
|
954
|
+
const fields = fieldsFor(form),
|
|
955
|
+
steps = formSteps(form, fields),
|
|
956
|
+
currentStep = stepAt(steps, field, fields.length);
|
|
957
|
+
const next = currentStep + direction;
|
|
958
|
+
if (direction > 0)
|
|
959
|
+
for (const i of steps[currentStep]?.indexes || []) {
|
|
960
|
+
const f = fields[i],
|
|
961
|
+
value = form.values[f.key] || "";
|
|
962
|
+
if (f.min && value.trim().length < f.min) {
|
|
963
|
+
setField(i);
|
|
964
|
+
setCursor(graphemes(value).length);
|
|
965
|
+
setError(f.label + " needs at least " + f.min + " characters.");
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (next >= steps.length) {
|
|
970
|
+
prepare();
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (next < 0) return;
|
|
974
|
+
setField(steps[next].indexes[0]);
|
|
975
|
+
setCursor(
|
|
976
|
+
graphemes(form.values[fields[steps[next].indexes[0]].key] || "").length,
|
|
977
|
+
);
|
|
978
|
+
setError("");
|
|
979
|
+
}
|
|
980
|
+
const activeSteps = form ? formSteps(form, fieldsFor(form)) : [],
|
|
981
|
+
activeStep = form ? stepAt(activeSteps, field, fieldsFor(form).length) : 0;
|
|
982
|
+
const quickItems = palette
|
|
983
|
+
? paletteItems({
|
|
984
|
+
query: palette.query,
|
|
985
|
+
actions,
|
|
986
|
+
history: history.current,
|
|
987
|
+
route,
|
|
988
|
+
})
|
|
989
|
+
: [];
|
|
990
|
+
function chooseQuick(item) {
|
|
991
|
+
if (!item) return;
|
|
992
|
+
setPalette(null);
|
|
993
|
+
if (item.route) {
|
|
994
|
+
navigate(item.route);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (item.id.startsWith("action:")) {
|
|
998
|
+
runAction(item.id.slice(7));
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (item.id.startsWith("nav:")) {
|
|
1002
|
+
chooseNav(Number(item.id.slice(4)));
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (item.id.startsWith("create:")) {
|
|
1006
|
+
const kind = item.id.slice(7);
|
|
1007
|
+
if (["project", "event"].includes(kind)) composeCatalog(kind);
|
|
1008
|
+
else composePublication(kind);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (item.id === "command") {
|
|
1012
|
+
runCommand(item.command);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (item.id === "search")
|
|
1016
|
+
navigate({ view: "search", query: item.query, page: 1 });
|
|
1017
|
+
}
|
|
802
1018
|
async function task(work) {
|
|
803
1019
|
if (busy.current) return;
|
|
804
1020
|
busy.current = true;
|
|
@@ -1234,6 +1450,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1234
1450
|
);
|
|
1235
1451
|
}
|
|
1236
1452
|
function openForm(next) {
|
|
1453
|
+
setFormNav(null);
|
|
1454
|
+
setNotice("");
|
|
1237
1455
|
setBooting(false);
|
|
1238
1456
|
setForm({
|
|
1239
1457
|
...next,
|
|
@@ -1398,6 +1616,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1398
1616
|
}
|
|
1399
1617
|
async function prepare() {
|
|
1400
1618
|
if (!form) return;
|
|
1619
|
+
setFormNav(null);
|
|
1401
1620
|
if (form.kind === "catalogFilter") {
|
|
1402
1621
|
await task(async () => {
|
|
1403
1622
|
const values = validateFilter(form.values);
|
|
@@ -1678,8 +1897,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1678
1897
|
return;
|
|
1679
1898
|
}
|
|
1680
1899
|
if (route.view === "thread" && item.entryType === "parent") {
|
|
1681
|
-
|
|
1682
|
-
setScroll(0);
|
|
1900
|
+
readItem(item);
|
|
1683
1901
|
return;
|
|
1684
1902
|
}
|
|
1685
1903
|
if (
|
|
@@ -1695,8 +1913,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1695
1913
|
return;
|
|
1696
1914
|
}
|
|
1697
1915
|
if (route.view === "feedback") {
|
|
1698
|
-
|
|
1699
|
-
setScroll(0);
|
|
1916
|
+
readItem(item);
|
|
1700
1917
|
return;
|
|
1701
1918
|
}
|
|
1702
1919
|
navigate({
|
|
@@ -1864,8 +2081,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1864
2081
|
}
|
|
1865
2082
|
if (id === "read") {
|
|
1866
2083
|
if (current?.post) {
|
|
1867
|
-
|
|
1868
|
-
setScroll(0);
|
|
2084
|
+
readItem(selected);
|
|
1869
2085
|
} else openSelected();
|
|
1870
2086
|
} else if (id === "reply") reply();
|
|
1871
2087
|
else if (id === "comment") lineFeedback();
|
|
@@ -1891,8 +2107,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1891
2107
|
invalidate("Received notifications marked as read.");
|
|
1892
2108
|
});
|
|
1893
2109
|
}
|
|
1894
|
-
async function runCommand() {
|
|
1895
|
-
const value = command.trim();
|
|
2110
|
+
async function runCommand(override) {
|
|
2111
|
+
const value = (override ?? command).trim();
|
|
1896
2112
|
if (!value) return;
|
|
1897
2113
|
setCommand("");
|
|
1898
2114
|
setCommandCursor(0);
|
|
@@ -1924,6 +2140,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1924
2140
|
navigate({ view: parsed.command, id: parsed.arg, page: 1 });
|
|
1925
2141
|
return;
|
|
1926
2142
|
}
|
|
2143
|
+
if (parsed.command === "actions") {
|
|
2144
|
+
setPalette({ query: "", cursor: 0, index: 0 });
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
1927
2147
|
if (parsed.command === "appearance") {
|
|
1928
2148
|
openAppearance();
|
|
1929
2149
|
return;
|
|
@@ -1969,7 +2189,19 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1969
2189
|
}
|
|
1970
2190
|
function changeField(next) {
|
|
1971
2191
|
const fields = fieldsFor(form),
|
|
1972
|
-
|
|
2192
|
+
steps = formSteps(form, fields),
|
|
2193
|
+
stepIndex = stepAt(steps, field, fields.length),
|
|
2194
|
+
step = steps[stepIndex];
|
|
2195
|
+
if (
|
|
2196
|
+
step &&
|
|
2197
|
+
!formNav &&
|
|
2198
|
+
((next > field && field === step.indexes.at(-1)) ||
|
|
2199
|
+
(next < field && field === step.indexes[0]))
|
|
2200
|
+
) {
|
|
2201
|
+
setFormNav(next < field && stepIndex > 0 ? "back" : "next");
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
const selected = (next + fields.length + 1) % (fields.length + 1);
|
|
1973
2205
|
setField(selected);
|
|
1974
2206
|
setCursor(
|
|
1975
2207
|
selected < fields.length
|
|
@@ -2033,6 +2265,45 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2033
2265
|
reconnect();
|
|
2034
2266
|
return;
|
|
2035
2267
|
}
|
|
2268
|
+
if (palette) {
|
|
2269
|
+
if (keyEvent.escape) {
|
|
2270
|
+
setPalette(null);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
if (keyEvent.upArrow || keyEvent.downArrow) {
|
|
2274
|
+
setPalette((old) => ({
|
|
2275
|
+
...old,
|
|
2276
|
+
index: Math.max(
|
|
2277
|
+
0,
|
|
2278
|
+
Math.min(
|
|
2279
|
+
quickItems.length - 1,
|
|
2280
|
+
old.index + (keyEvent.downArrow ? 1 : -1),
|
|
2281
|
+
),
|
|
2282
|
+
),
|
|
2283
|
+
}));
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
if (keyEvent.return) {
|
|
2287
|
+
chooseQuick(quickItems[palette.index]);
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
const updated = editText(palette.query, palette.cursor, input, keyEvent, {
|
|
2291
|
+
max: 120,
|
|
2292
|
+
});
|
|
2293
|
+
setPalette({ query: updated.value, cursor: updated.cursor, index: 0 });
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
if (
|
|
2297
|
+
keyEvent.ctrl &&
|
|
2298
|
+
input === "k" &&
|
|
2299
|
+
!form &&
|
|
2300
|
+
!preview &&
|
|
2301
|
+
focus !== "prompt"
|
|
2302
|
+
) {
|
|
2303
|
+
setActionIndex(null);
|
|
2304
|
+
setPalette({ query: "", cursor: 0, index: 0 });
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2036
2307
|
if (actionIndex !== null) {
|
|
2037
2308
|
if (keyEvent.escape || input === "x" || input === "X")
|
|
2038
2309
|
setActionIndex(null);
|
|
@@ -2043,36 +2314,65 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2043
2314
|
return;
|
|
2044
2315
|
}
|
|
2045
2316
|
if (reading) {
|
|
2046
|
-
const
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
)
|
|
2053
|
-
|
|
2317
|
+
const maximum = Math.max(0, readerRows.length - readerHeight);
|
|
2318
|
+
if (readerFind.editing) {
|
|
2319
|
+
if (keyEvent.escape) {
|
|
2320
|
+
setReaderFind((old) => ({ ...old, editing: false }));
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
if (keyEvent.return) {
|
|
2324
|
+
setReaderFind((old) => ({ ...old, editing: false }));
|
|
2325
|
+
if (readerHits.length)
|
|
2326
|
+
setScroll(readerHits.find((n) => n >= scroll) ?? readerHits[0]);
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
const updated = editText(
|
|
2330
|
+
readerFind.query,
|
|
2331
|
+
readerFind.cursor,
|
|
2332
|
+
input,
|
|
2333
|
+
keyEvent,
|
|
2334
|
+
{ max: 120 },
|
|
2335
|
+
);
|
|
2336
|
+
setReaderFind({
|
|
2337
|
+
query: updated.value,
|
|
2338
|
+
cursor: updated.cursor,
|
|
2339
|
+
editing: true,
|
|
2340
|
+
});
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
if (input === "/") {
|
|
2344
|
+
setReaderFind((old) => ({ ...old, editing: true }));
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
if ((input === "n" || input === "N") && readerFind.query) {
|
|
2348
|
+
setScroll(nextReadingMatch(readerHits, scroll, input === "n" ? 1 : -1));
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2054
2351
|
if (keyEvent.escape || keyEvent.return || input === " ") {
|
|
2055
|
-
|
|
2352
|
+
closeReading();
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
if (keyEvent.home) {
|
|
2056
2356
|
setScroll(0);
|
|
2057
2357
|
return;
|
|
2058
2358
|
}
|
|
2359
|
+
if (keyEvent.end) {
|
|
2360
|
+
setScroll(maximum);
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2059
2363
|
if (keyEvent.upArrow || keyEvent.pageUp)
|
|
2060
|
-
setScroll((v) =>
|
|
2061
|
-
Math.max(0, v - (keyEvent.pageUp ? contentHeight : 1)),
|
|
2062
|
-
);
|
|
2364
|
+
setScroll((v) => Math.max(0, v - (keyEvent.pageUp ? readerHeight : 1)));
|
|
2063
2365
|
else if (keyEvent.downArrow || keyEvent.pageDown)
|
|
2064
2366
|
setScroll((v) =>
|
|
2065
|
-
Math.min(maximum, v + (keyEvent.pageDown ?
|
|
2367
|
+
Math.min(maximum, v + (keyEvent.pageDown ? readerHeight : 1)),
|
|
2066
2368
|
);
|
|
2067
2369
|
else if (input === "r") {
|
|
2068
|
-
|
|
2370
|
+
closeReading();
|
|
2069
2371
|
reply();
|
|
2070
2372
|
} else if (input === "e" || input === "E") {
|
|
2071
|
-
|
|
2373
|
+
closeReading();
|
|
2072
2374
|
editSelected();
|
|
2073
|
-
} else if (input === "d" || input === "D")
|
|
2074
|
-
deleteSelected();
|
|
2075
|
-
}
|
|
2375
|
+
} else if (input === "d" || input === "D") deleteSelected();
|
|
2076
2376
|
return;
|
|
2077
2377
|
}
|
|
2078
2378
|
if (preview) {
|
|
@@ -2097,7 +2397,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2097
2397
|
return;
|
|
2098
2398
|
}
|
|
2099
2399
|
if (form) {
|
|
2100
|
-
if (
|
|
2400
|
+
if (
|
|
2401
|
+
keyEvent.ctrl &&
|
|
2402
|
+
input === "o" &&
|
|
2403
|
+
form.kind === "catalog" &&
|
|
2404
|
+
!formNav
|
|
2405
|
+
) {
|
|
2101
2406
|
const active = fieldsFor(form)[field];
|
|
2102
2407
|
if (["logo", "banner", "screenshots"].includes(active?.key)) {
|
|
2103
2408
|
setPicker({ mediaKey: active.key });
|
|
@@ -2124,7 +2429,15 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2124
2429
|
setNotice("Default preview restored. Ctrl+S saves; Esc cancels.");
|
|
2125
2430
|
return;
|
|
2126
2431
|
}
|
|
2127
|
-
if (
|
|
2432
|
+
if (
|
|
2433
|
+
keyEvent.ctrl &&
|
|
2434
|
+
["n", "p"].includes(input) &&
|
|
2435
|
+
formSteps(form, fieldsFor(form)).length
|
|
2436
|
+
) {
|
|
2437
|
+
moveStep(input === "n" ? 1 : -1);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
if (keyEvent.ctrl && input === "e" && !formNav) {
|
|
2128
2441
|
externalEdit();
|
|
2129
2442
|
return;
|
|
2130
2443
|
}
|
|
@@ -2132,6 +2445,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2132
2445
|
keyEvent.ctrl &&
|
|
2133
2446
|
["o", "g"].includes(input) &&
|
|
2134
2447
|
form.kind === "publication" &&
|
|
2448
|
+
!formNav &&
|
|
2135
2449
|
form.values.kind === "review"
|
|
2136
2450
|
) {
|
|
2137
2451
|
setPicker(input === "g" ? "changes" : "files");
|
|
@@ -2155,6 +2469,39 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2155
2469
|
prepare();
|
|
2156
2470
|
return;
|
|
2157
2471
|
}
|
|
2472
|
+
if (formNav) {
|
|
2473
|
+
const fields = fieldsFor(form),
|
|
2474
|
+
steps = formSteps(form, fields),
|
|
2475
|
+
i = stepAt(steps, field, fields.length),
|
|
2476
|
+
step = steps[i];
|
|
2477
|
+
if (keyEvent.return) {
|
|
2478
|
+
moveStep(formNav === "back" ? -1 : 1);
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
if (keyEvent.leftArrow || keyEvent.upArrow) {
|
|
2482
|
+
setFormNav(i > 0 ? "back" : "next");
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
if (keyEvent.rightArrow || keyEvent.downArrow) {
|
|
2486
|
+
setFormNav("next");
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
if (keyEvent.tab) {
|
|
2490
|
+
if (keyEvent.shift && formNav === "next" && i > 0) {
|
|
2491
|
+
setFormNav("back");
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
if (!keyEvent.shift && formNav === "back") {
|
|
2495
|
+
setFormNav("next");
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
const next = keyEvent.shift ? step.indexes.at(-1) : step.indexes[0];
|
|
2499
|
+
setFormNav(null);
|
|
2500
|
+
setField(next);
|
|
2501
|
+
setCursor(graphemes(form.values[fields[next].key] || "").length);
|
|
2502
|
+
}
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2158
2505
|
if (keyEvent.tab) {
|
|
2159
2506
|
changeField(field + (keyEvent.shift ? -1 : 1));
|
|
2160
2507
|
return;
|
|
@@ -2409,8 +2756,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2409
2756
|
selected &&
|
|
2410
2757
|
["post", "thread", "feedback"].includes(route.view)
|
|
2411
2758
|
) {
|
|
2412
|
-
|
|
2413
|
-
setScroll(0);
|
|
2759
|
+
readItem(selected);
|
|
2414
2760
|
return;
|
|
2415
2761
|
}
|
|
2416
2762
|
if (keyEvent.return) {
|
|
@@ -2501,15 +2847,23 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2501
2847
|
reportSelected();
|
|
2502
2848
|
}
|
|
2503
2849
|
});
|
|
2850
|
+
const compactFieldHelp =
|
|
2851
|
+
form && !formNav && form.kind !== "appearance" && contentHeight < 8
|
|
2852
|
+
? fieldGuidance(fieldsFor(form)[field], {
|
|
2853
|
+
wizard: !!activeSteps.length,
|
|
2854
|
+
project: form.kind === "catalog" && form.entity === "project",
|
|
2855
|
+
}).detail
|
|
2856
|
+
: "";
|
|
2504
2857
|
const status =
|
|
2505
2858
|
error ||
|
|
2859
|
+
compactFieldHelp ||
|
|
2506
2860
|
liveStatus ||
|
|
2507
2861
|
notice ||
|
|
2508
2862
|
(pending
|
|
2509
2863
|
? "Saving…"
|
|
2510
2864
|
: loading
|
|
2511
2865
|
? "Loading…"
|
|
2512
|
-
: "
|
|
2866
|
+
: "Ctrl+K quick actions · Shared account · same permissions");
|
|
2513
2867
|
const spin =
|
|
2514
2868
|
effectiveAppearance.motion === "reduced" ? "·" : ["◐", "◓", "◑", "◒"][tick];
|
|
2515
2869
|
let center;
|
|
@@ -2652,6 +3006,60 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2652
3006
|
animate: true,
|
|
2653
3007
|
}),
|
|
2654
3008
|
);
|
|
3009
|
+
} else if (palette) {
|
|
3010
|
+
const count = Math.max(1, bodyHeight - 5),
|
|
3011
|
+
first = Math.max(0, palette.index - count + 1);
|
|
3012
|
+
center = h(
|
|
3013
|
+
Box,
|
|
3014
|
+
{
|
|
3015
|
+
borderStyle: colors.frame,
|
|
3016
|
+
borderColor: colors.purple,
|
|
3017
|
+
backgroundColor: colors.panel,
|
|
3018
|
+
flexDirection: "column",
|
|
3019
|
+
paddingX: 1,
|
|
3020
|
+
height: bodyHeight,
|
|
3021
|
+
overflow: "hidden",
|
|
3022
|
+
},
|
|
3023
|
+
h(TextLine, {
|
|
3024
|
+
text: "QUICK ACTIONS · type to filter",
|
|
3025
|
+
width: width - 6,
|
|
3026
|
+
color: colors.purple,
|
|
3027
|
+
bold: true,
|
|
3028
|
+
}),
|
|
3029
|
+
h(InputText, {
|
|
3030
|
+
value: palette.query,
|
|
3031
|
+
cursor: palette.cursor,
|
|
3032
|
+
width: width - 6,
|
|
3033
|
+
}),
|
|
3034
|
+
h(
|
|
3035
|
+
Box,
|
|
3036
|
+
{ flexDirection: "column", height: count, overflow: "hidden" },
|
|
3037
|
+
...quickItems.slice(first, first + count).map((item, i) =>
|
|
3038
|
+
h(
|
|
3039
|
+
Text,
|
|
3040
|
+
{
|
|
3041
|
+
key: item.id,
|
|
3042
|
+
wrap: "truncate",
|
|
3043
|
+
color: first + i === palette.index ? colors.blue : colors.text,
|
|
3044
|
+
backgroundColor:
|
|
3045
|
+
first + i === palette.index ? colors.selected : undefined,
|
|
3046
|
+
},
|
|
3047
|
+
clip(
|
|
3048
|
+
(first + i === palette.index ? "› " : " ") +
|
|
3049
|
+
item.label +
|
|
3050
|
+
" · " +
|
|
3051
|
+
item.group,
|
|
3052
|
+
width - 6,
|
|
3053
|
+
),
|
|
3054
|
+
),
|
|
3055
|
+
),
|
|
3056
|
+
),
|
|
3057
|
+
h(TextLine, {
|
|
3058
|
+
text: "↑↓ choose · Enter open · Esc return · No action runs while typing",
|
|
3059
|
+
width: width - 6,
|
|
3060
|
+
color: colors.muted,
|
|
3061
|
+
}),
|
|
3062
|
+
);
|
|
2655
3063
|
} else if (dialog) {
|
|
2656
3064
|
center = h(
|
|
2657
3065
|
Box,
|
|
@@ -2691,19 +3099,41 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2691
3099
|
} else {
|
|
2692
3100
|
let body;
|
|
2693
3101
|
if (reading) {
|
|
2694
|
-
const
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
3102
|
+
const position = Math.min(
|
|
3103
|
+
scroll,
|
|
3104
|
+
Math.max(0, readerRows.length - readerHeight),
|
|
3105
|
+
);
|
|
3106
|
+
const matches = new Set(readerHits);
|
|
3107
|
+
body = h(
|
|
3108
|
+
Box,
|
|
3109
|
+
{ flexDirection: "column", height: contentHeight, overflow: "hidden" },
|
|
3110
|
+
readerFind.editing
|
|
3111
|
+
? h(InputText, {
|
|
3112
|
+
value: readerFind.query,
|
|
3113
|
+
cursor: readerFind.cursor,
|
|
3114
|
+
width: contentWidth,
|
|
3115
|
+
})
|
|
3116
|
+
: h(TextLine, {
|
|
3117
|
+
text: readerFind.query
|
|
3118
|
+
? `Find: ${readerFind.query} · ${readerHits.length} matching lines · n next / N previous`
|
|
3119
|
+
: `Reply #${reading.id} · ${stamp(reading.created)} · / find in text`,
|
|
3120
|
+
width: contentWidth,
|
|
3121
|
+
color: colors.muted,
|
|
3122
|
+
}),
|
|
3123
|
+
h(FrameRows, {
|
|
3124
|
+
rows: readerRows.map((row, i) =>
|
|
3125
|
+
matches.has(i) ? { ...row, bold: true, color: colors.blue } : row,
|
|
3126
|
+
),
|
|
3127
|
+
width: contentWidth,
|
|
3128
|
+
height: readerHeight,
|
|
3129
|
+
offset: position,
|
|
3130
|
+
}),
|
|
3131
|
+
h(TextLine, {
|
|
3132
|
+
text: `Lines ${Math.min(position + 1, readerRows.length)}–${Math.min(position + readerHeight, readerRows.length)} of ${readerRows.length} · Home / End · Esc returns`,
|
|
3133
|
+
width: contentWidth,
|
|
3134
|
+
color: colors.muted,
|
|
3135
|
+
}),
|
|
2700
3136
|
);
|
|
2701
|
-
body = h(FrameRows, {
|
|
2702
|
-
rows: lines,
|
|
2703
|
-
width: contentWidth,
|
|
2704
|
-
height: contentHeight,
|
|
2705
|
-
offset: Math.min(scroll, Math.max(0, lines.length - contentHeight)),
|
|
2706
|
-
});
|
|
2707
3137
|
} else if (preview) {
|
|
2708
3138
|
const payload = preview.payload,
|
|
2709
3139
|
audience =
|
|
@@ -2777,6 +3207,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2777
3207
|
body = h(form.kind === "appearance" ? AppearanceView : FormView, {
|
|
2778
3208
|
form,
|
|
2779
3209
|
active: field,
|
|
3210
|
+
navFocus: formNav,
|
|
2780
3211
|
cursor,
|
|
2781
3212
|
width: contentWidth,
|
|
2782
3213
|
height: contentHeight,
|
|
@@ -3021,7 +3452,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
3021
3452
|
text: form
|
|
3022
3453
|
? (form.kind === "appearance"
|
|
3023
3454
|
? "Local preferences · Preview now · Ctrl+S save · Esc cancel"
|
|
3024
|
-
:
|
|
3455
|
+
: (activeSteps.length
|
|
3456
|
+
? `Step ${activeStep + 1}/${activeSteps.length} · ${activeSteps[activeStep].label} · `
|
|
3457
|
+
: "") + "Tab field · Ctrl+N/P steps · Ctrl+S preview") +
|
|
3025
3458
|
(draftSaved ? " · Draft saved locally" : "")
|
|
3026
3459
|
: preview
|
|
3027
3460
|
? "Scroll to review · ← → choose · Enter confirms"
|
|
@@ -3096,8 +3529,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
3096
3529
|
compact && focus === "nav" ? null : pane,
|
|
3097
3530
|
);
|
|
3098
3531
|
}
|
|
3099
|
-
const hint =
|
|
3100
|
-
|
|
3532
|
+
const hint = palette
|
|
3533
|
+
? "Type to filter · ↑↓ choose · Enter open · Esc return"
|
|
3534
|
+
: ["projects", "events"].includes(route.view) &&
|
|
3535
|
+
!form &&
|
|
3536
|
+
!preview &&
|
|
3537
|
+
!picker
|
|
3101
3538
|
? "↑↓ select · Enter open · F filters · N new · [ ] pages · / search"
|
|
3102
3539
|
: ["project", "event"].includes(route.view) && !form && !preview
|
|
3103
3540
|
? "↑↓ scroll · X actions · E edit your publication · Esc back"
|
|
@@ -3110,11 +3547,17 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
3110
3547
|
: picker
|
|
3111
3548
|
? "Project files stay local until publication is confirmed"
|
|
3112
3549
|
: reading
|
|
3113
|
-
?
|
|
3550
|
+
? readerFind.editing
|
|
3551
|
+
? "Find in this text · Enter locate · Esc finish search"
|
|
3552
|
+
: "↑↓ scroll · / find · n/N match · Home/End · Esc return"
|
|
3114
3553
|
: preview
|
|
3115
3554
|
? "← → choose · Enter confirm · Esc edit"
|
|
3116
3555
|
: form
|
|
3117
|
-
?
|
|
3556
|
+
? formNav
|
|
3557
|
+
? `${stepControls(activeSteps, activeStep).find((control) => control.id === formNav)?.label || "Next"} · Enter select · Tab fields · Ctrl+S preview`
|
|
3558
|
+
: fieldsFor(form)[field]?.multiline
|
|
3559
|
+
? "Enter new line · Tab next field · Ctrl+N next step · Ctrl+S preview"
|
|
3560
|
+
: "Enter/Tab next field · Ctrl+N/P steps · Ctrl+S preview"
|
|
3118
3561
|
: phase !== "ready"
|
|
3119
3562
|
? "Enter connect / open browser · Ctrl+C exit"
|
|
3120
3563
|
: route.view === "feedback"
|
package/lib/workflow.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { clean } from "./client.mjs";
|
|
2
|
+
// Steps only organize local input. The existing full validation/preview still gates writes.
|
|
3
|
+
export function formSteps(form, fields) {
|
|
4
|
+
let groups;
|
|
5
|
+
if (form.kind === "publication")
|
|
6
|
+
groups = [
|
|
7
|
+
["About", ["kind", "title", "topic", "community_id"]],
|
|
8
|
+
["Content", ["file", "lines", "review_focus", "demo_url", "body"]],
|
|
9
|
+
];
|
|
10
|
+
else if (form.kind === "catalog")
|
|
11
|
+
groups =
|
|
12
|
+
form.entity === "project"
|
|
13
|
+
? [
|
|
14
|
+
["Basics", ["name", "tagline", "topic", "stage", "stack"]],
|
|
15
|
+
["Story", ["description", "help", "problem", "audience"]],
|
|
16
|
+
["People & links", ["url", "repo", "team", "links"]],
|
|
17
|
+
["Images", ["logo", "screenshots"]],
|
|
18
|
+
]
|
|
19
|
+
: [
|
|
20
|
+
[
|
|
21
|
+
"Schedule",
|
|
22
|
+
["title", "topic", "local_start", "timezone", "duration"],
|
|
23
|
+
],
|
|
24
|
+
["Details", ["url", "description", "links"]],
|
|
25
|
+
["Banner", ["banner"]],
|
|
26
|
+
];
|
|
27
|
+
else return [];
|
|
28
|
+
return groups
|
|
29
|
+
.map(([label, keys]) => ({
|
|
30
|
+
label,
|
|
31
|
+
indexes: fields.flatMap((field, i) =>
|
|
32
|
+
keys.includes(field.key) ? [i] : [],
|
|
33
|
+
),
|
|
34
|
+
}))
|
|
35
|
+
.filter((step) => step.indexes.length);
|
|
36
|
+
}
|
|
37
|
+
export function stepAt(steps, active, length) {
|
|
38
|
+
return Math.max(
|
|
39
|
+
0,
|
|
40
|
+
active === length
|
|
41
|
+
? steps.length - 1
|
|
42
|
+
: steps.findIndex((step) => step.indexes.includes(active)),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
export const rowText = (row) =>
|
|
46
|
+
clean(
|
|
47
|
+
typeof row === "string"
|
|
48
|
+
? row
|
|
49
|
+
: (row.text ?? row.spans?.map((s) => s.text).join("") ?? ""),
|
|
50
|
+
);
|
|
51
|
+
export function readingMatches(rows, query) {
|
|
52
|
+
const term = clean(query).normalize("NFKC").toLocaleLowerCase();
|
|
53
|
+
return term
|
|
54
|
+
? rows.flatMap((row, i) =>
|
|
55
|
+
rowText(row).normalize("NFKC").toLocaleLowerCase().includes(term)
|
|
56
|
+
? [i]
|
|
57
|
+
: [],
|
|
58
|
+
)
|
|
59
|
+
: [];
|
|
60
|
+
}
|
|
61
|
+
export function nextReadingMatch(matches, offset, direction = 1) {
|
|
62
|
+
if (!matches.length) return offset;
|
|
63
|
+
return direction > 0
|
|
64
|
+
? (matches.find((n) => n > offset) ?? matches[0])
|
|
65
|
+
: ([...matches].reverse().find((n) => n < offset) ?? matches.at(-1));
|
|
66
|
+
}
|
|
67
|
+
export class ReadingMemory {
|
|
68
|
+
constructor(limit = 80) {
|
|
69
|
+
this.limit = limit;
|
|
70
|
+
this.items = new Map();
|
|
71
|
+
}
|
|
72
|
+
save(key, offset, rows) {
|
|
73
|
+
this.items.delete(key);
|
|
74
|
+
this.items.set(key, { offset, anchor: rowText(rows[offset] || "") });
|
|
75
|
+
while (this.items.size > this.limit)
|
|
76
|
+
this.items.delete(this.items.keys().next().value);
|
|
77
|
+
}
|
|
78
|
+
restore(key, rows, height) {
|
|
79
|
+
const entry = this.items.get(key);
|
|
80
|
+
if (!entry) return 0;
|
|
81
|
+
let offset = entry.offset;
|
|
82
|
+
if (entry.anchor && rowText(rows[offset] || "") !== entry.anchor) {
|
|
83
|
+
const found = rows.findIndex((row) => rowText(row) === entry.anchor);
|
|
84
|
+
if (found >= 0) offset = found;
|
|
85
|
+
}
|
|
86
|
+
return Math.max(0, Math.min(offset, rows.length - height));
|
|
87
|
+
}
|
|
88
|
+
clear() {
|
|
89
|
+
this.items.clear();
|
|
90
|
+
}
|
|
91
|
+
}
|