@tenyokjs/devdevian-cli 0.6.0-alpha.1 → 0.7.0-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 +25 -1
- package/VERSIONS.md +16 -1
- package/bin/devdevian.mjs +16 -1
- package/lib/help.mjs +40 -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 +378 -60
- package/lib/workflow.mjs +91 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,22 @@ Login opens a browser. Compare the code shown in the terminal, sign in to Devdev
|
|
|
12
12
|
|
|
13
13
|
Version-by-version changes and the customization roadmap: [CLI version history](VERSIONS.md).
|
|
14
14
|
|
|
15
|
+
## A look inside
|
|
16
|
+
|
|
17
|
+
Actual CLI captures from isolated demonstration accounts. Your terminal uses your real forum account and permissions.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
<details>
|
|
22
|
+
<summary>Code review and appearance</summary>
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
</details>
|
|
28
|
+
|
|
29
|
+
Run `devdevian --help` for script commands and workspace shortcuts. Interactive help has a large wordmark and selective accents; `NO_COLOR=1` keeps it uncolored, narrow terminals use a compact heading, and redirected output stays plain.
|
|
30
|
+
|
|
15
31
|
## Live workspace
|
|
16
32
|
|
|
17
33
|
The default `devdevian` command and `devdevian tui` open an alternate terminal screen. Navigation, conversations, inline forms, previews and search update in that screen; exiting restores your normal terminal. The blue and purple layout adapts to the terminal size, with compact navigation below 90 columns. Minimum size: 60 columns × 18 rows.
|
|
@@ -43,6 +59,14 @@ Calendar export from **X** writes a new `.ics` file inside the open workspace an
|
|
|
43
59
|
|
|
44
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.
|
|
45
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** still moves through all fields and crosses steps. **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
|
+
|
|
46
70
|
## Meet Devi
|
|
47
71
|
|
|
48
72
|
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.
|
|
@@ -142,7 +166,7 @@ Commands are examples; replace IDs, file paths and topic IDs with your own. Run
|
|
|
142
166
|
|
|
143
167
|
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.
|
|
144
168
|
|
|
145
|
-
`--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
|
|
169
|
+
`--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.
|
|
146
170
|
|
|
147
171
|
## Connected devices and credentials
|
|
148
172
|
|
package/VERSIONS.md
CHANGED
|
@@ -6,7 +6,22 @@ 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.0-alpha.1 — Everyday flow
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
## 0.6.1-alpha.1 — A clearer first impression
|
|
20
|
+
|
|
21
|
+
- Added a large terminal wordmark and selective blue/violet accents to `--help`. Compact terminals, `NO_COLOR`, redirected output and JSON help remain readable without ANSI decoration.
|
|
22
|
+
- Help now points to the interactive Projects/Events commands and actions. CLI documentation includes real terminal captures and install/login/workspace guidance.
|
|
23
|
+
- The website introduces the CLI near the top, with keyboard-accessible screenshot tabs, copyable install/review commands and GitHub documentation links. A cached public-only activity counter updates while visible and respects reduced motion.
|
|
24
|
+
- No schema migration, account/credential change or production-data reset. The CLI package and web/API keep independent versions.
|
|
10
25
|
|
|
11
26
|
## 0.6.0-alpha.1 — Projects and Events
|
|
12
27
|
|
package/bin/devdevian.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
loadAppearance,
|
|
24
24
|
saveAppearance,
|
|
25
25
|
} from "../lib/appearance.mjs";
|
|
26
|
+
import { renderHelp } from "../lib/help.mjs";
|
|
26
27
|
const version = packageInfo.version;
|
|
27
28
|
const options = {
|
|
28
29
|
host: { type: "string" },
|
|
@@ -113,6 +114,12 @@ const help = `Devdevian CLI ${version} · alpha
|
|
|
113
114
|
devdevian review reopen POST_ID REVIEW_ID
|
|
114
115
|
devdevian communities [--page 2]
|
|
115
116
|
|
|
117
|
+
Inside the workspace: /projects, /project ID, /events, /event ID
|
|
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
|
+
Full reply text: / find, n/N matches, Home/End, Esc remembers your place
|
|
121
|
+
N creates · F filters · E edits your publication · X opens context actions
|
|
122
|
+
|
|
116
123
|
--theme NAME midnight | nebula | daylight | contrast
|
|
117
124
|
--accent NAME blue | violet | cyan | rose
|
|
118
125
|
--density NAME comfortable | compact
|
|
@@ -257,7 +264,15 @@ async function login(flags) {
|
|
|
257
264
|
}
|
|
258
265
|
async function execute(args, flags) {
|
|
259
266
|
if (flags.help) {
|
|
260
|
-
|
|
267
|
+
process.stdout.write(
|
|
268
|
+
renderHelp(help, {
|
|
269
|
+
tty: !!process.stdout.isTTY,
|
|
270
|
+
columns: process.stdout.columns || 80,
|
|
271
|
+
noColor:
|
|
272
|
+
process.env.NO_COLOR !== undefined || process.env.TERM === "dumb",
|
|
273
|
+
json: !!flags.json,
|
|
274
|
+
}) + "\n",
|
|
275
|
+
);
|
|
261
276
|
return;
|
|
262
277
|
}
|
|
263
278
|
if (flags.version) {
|
package/lib/help.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Static wordmark; no terminal probes, image protocols or account reads.
|
|
2
|
+
const font = {
|
|
3
|
+
D: ["████ ", "█ █", "█ █", "█ █", "████ "],
|
|
4
|
+
E: ["█████", "█ ", "████ ", "█ ", "█████"],
|
|
5
|
+
V: ["█ █", "█ █", "█ █", " █ █ ", " █ "],
|
|
6
|
+
I: ["█████", " █ ", " █ ", " █ ", "█████"],
|
|
7
|
+
A: [" ███ ", "█ █", "█████", "█ █", "█ █"],
|
|
8
|
+
N: ["█ █", "██ █", "█ █ █", "█ ██", "█ █"],
|
|
9
|
+
};
|
|
10
|
+
export function renderHelp(
|
|
11
|
+
text,
|
|
12
|
+
{ tty = false, columns = 80, noColor = false, json = false } = {},
|
|
13
|
+
) {
|
|
14
|
+
if (!tty || json) return text;
|
|
15
|
+
const paint = (value, color) =>
|
|
16
|
+
noColor ? value : `\x1b[38;5;${color}m${value}\x1b[0m`;
|
|
17
|
+
const banner =
|
|
18
|
+
columns >= 56
|
|
19
|
+
? Array.from({ length: 5 }, (_, row) =>
|
|
20
|
+
paint(
|
|
21
|
+
[..."DEVDEVIAN"].map((c) => font[c][row]).join(" "),
|
|
22
|
+
[111, 105, 141, 177, 183][row],
|
|
23
|
+
),
|
|
24
|
+
).join("\n")
|
|
25
|
+
: paint("DEVDEVIAN", 141);
|
|
26
|
+
const content = text
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((line) => {
|
|
29
|
+
if (line.startsWith("Devdevian CLI")) return paint(line, 183);
|
|
30
|
+
return line.replace(
|
|
31
|
+
/^( devdevian)(\s+[a-z]+(?:\s+(?:open|create|list|comment|thread|reply|resolve|reopen))?)?|(--[a-z][a-z-]*)/g,
|
|
32
|
+
(match, command, rest) =>
|
|
33
|
+
command
|
|
34
|
+
? paint(command, 111) + (rest ? paint(rest, 183) : "")
|
|
35
|
+
: paint(match, 147),
|
|
36
|
+
);
|
|
37
|
+
})
|
|
38
|
+
.join("\n");
|
|
39
|
+
return `\n${banner}\n\n${content}`;
|
|
40
|
+
}
|
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,14 @@ import {
|
|
|
43
43
|
appearanceFlags,
|
|
44
44
|
} from "./appearance.mjs";
|
|
45
45
|
import { liveLoop, anchoredIndex } from "./live.mjs";
|
|
46
|
+
import { paletteItems } from "./palette.mjs";
|
|
47
|
+
import {
|
|
48
|
+
formSteps,
|
|
49
|
+
stepAt,
|
|
50
|
+
readingMatches,
|
|
51
|
+
nextReadingMatch,
|
|
52
|
+
ReadingMemory,
|
|
53
|
+
} from "./workflow.mjs";
|
|
46
54
|
import { contextActions } from "./context-actions.mjs";
|
|
47
55
|
import { editExternal } from "./external-editor.mjs";
|
|
48
56
|
import { markdownRows } from "./rich-text.mjs";
|
|
@@ -106,8 +114,13 @@ function openBrowser(url) {
|
|
|
106
114
|
child.unref();
|
|
107
115
|
}
|
|
108
116
|
function fieldsFor(form) {
|
|
109
|
-
if (["catalog", "catalogFilter", "calendar"].includes(form.kind))
|
|
110
|
-
|
|
117
|
+
if (["catalog", "catalogFilter", "calendar"].includes(form.kind)) {
|
|
118
|
+
const fields = catalogFields(form),
|
|
119
|
+
steps = formSteps(form, fields);
|
|
120
|
+
return steps.length
|
|
121
|
+
? steps.flatMap((step) => step.indexes.map((i) => fields[i]))
|
|
122
|
+
: fields;
|
|
123
|
+
}
|
|
111
124
|
if (form.kind === "appearance") return appearanceFields;
|
|
112
125
|
if (form.kind === "publication")
|
|
113
126
|
return [
|
|
@@ -280,8 +293,12 @@ function FormView({ form, active, cursor, width, height }) {
|
|
|
280
293
|
const colors = useTheme();
|
|
281
294
|
const fields = fieldsFor(form),
|
|
282
295
|
rows = [];
|
|
283
|
-
|
|
296
|
+
const steps = formSteps(form, fields),
|
|
297
|
+
stepIndex = stepAt(steps, active, fields.length),
|
|
298
|
+
step = steps[stepIndex];
|
|
299
|
+
let selectedLine = rows.length;
|
|
284
300
|
fields.forEach((field, index) => {
|
|
301
|
+
if (step && !step.indexes.includes(index)) return;
|
|
285
302
|
if (index === active) selectedLine = rows.length;
|
|
286
303
|
rows.push({
|
|
287
304
|
text: (index === active ? "› " : " ") + field.label,
|
|
@@ -311,7 +328,10 @@ function FormView({ form, active, cursor, width, height }) {
|
|
|
311
328
|
)
|
|
312
329
|
: 0;
|
|
313
330
|
const visible = field.multiline
|
|
314
|
-
? Math.min(
|
|
331
|
+
? Math.min(
|
|
332
|
+
index === active ? Math.max(1, Math.min(12, height - 3)) : 3,
|
|
333
|
+
Math.max(2, text.length),
|
|
334
|
+
)
|
|
315
335
|
: 1,
|
|
316
336
|
start = index === active ? Math.max(0, caretLine - visible + 1) : 0;
|
|
317
337
|
rows.push(
|
|
@@ -326,15 +346,20 @@ function FormView({ form, active, cursor, width, height }) {
|
|
|
326
346
|
rows.push("");
|
|
327
347
|
});
|
|
328
348
|
if (active === fields.length) selectedLine = rows.length;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
349
|
+
if (!step || stepIndex === steps.length - 1)
|
|
350
|
+
rows.push({
|
|
351
|
+
text:
|
|
352
|
+
(active === fields.length ? "› " : " ") +
|
|
353
|
+
(form.kind === "appearance"
|
|
354
|
+
? "Save appearance [Ctrl+S]"
|
|
355
|
+
: form.kind === "catalogFilter"
|
|
356
|
+
? "Apply filters [Ctrl+S]"
|
|
357
|
+
: form.kind === "calendar"
|
|
358
|
+
? "Export calendar [Ctrl+S]"
|
|
359
|
+
: "Preview publication [Ctrl+S]"),
|
|
360
|
+
color: colors.blue,
|
|
361
|
+
bold: true,
|
|
362
|
+
});
|
|
338
363
|
const offset = Math.max(
|
|
339
364
|
0,
|
|
340
365
|
Math.min(selectedLine - (height >= 4 ? 1 : 0), rows.length - height),
|
|
@@ -576,6 +601,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
576
601
|
[picker, setPicker] = useState(null),
|
|
577
602
|
[draftSaved, setDraftSaved] = useState(false),
|
|
578
603
|
[actionIndex, setActionIndex] = useState(null),
|
|
604
|
+
[palette, setPalette] = useState(null),
|
|
605
|
+
[readerFind, setReaderFind] = useState({
|
|
606
|
+
query: "",
|
|
607
|
+
cursor: 0,
|
|
608
|
+
editing: false,
|
|
609
|
+
}),
|
|
579
610
|
[reviewFocus, setReviewFocus] = useState("notes"),
|
|
580
611
|
[reviewLine, setReviewLine] = useState(0),
|
|
581
612
|
[reviewAnchor, setReviewAnchor] = useState(null),
|
|
@@ -583,6 +614,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
583
614
|
[live, setLive] = useState(!flags["no-live"]),
|
|
584
615
|
[unread, setUnread] = useState(0),
|
|
585
616
|
[liveStatus, setLiveStatus] = useState("");
|
|
617
|
+
const readingMemory = useRef(new ReadingMemory()),
|
|
618
|
+
readingReturn = useRef(0);
|
|
586
619
|
const activity = useRef(Date.now()),
|
|
587
620
|
liveView = useRef(null),
|
|
588
621
|
notificationGeneration = useRef(0);
|
|
@@ -609,7 +642,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
609
642
|
);
|
|
610
643
|
const effectiveAppearance =
|
|
611
644
|
form?.kind === "appearance" ? normalizeAppearance(form.values) : appearance;
|
|
612
|
-
const colors =
|
|
645
|
+
const colors = useMemo(
|
|
646
|
+
() => themeFor(effectiveAppearance),
|
|
647
|
+
[effectiveAppearance],
|
|
648
|
+
),
|
|
613
649
|
stride = effectiveAppearance.density === "compact" ? 3 : 4;
|
|
614
650
|
const width = Math.max(1, columns),
|
|
615
651
|
height = Math.max(1, rows - 1),
|
|
@@ -644,6 +680,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
644
680
|
dialog ||
|
|
645
681
|
reading ||
|
|
646
682
|
actionIndex !== null ||
|
|
683
|
+
palette ||
|
|
647
684
|
loading ||
|
|
648
685
|
pending ||
|
|
649
686
|
focus === "prompt"
|
|
@@ -715,6 +752,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
715
752
|
const resetAccount = () => {
|
|
716
753
|
cache.current.clear();
|
|
717
754
|
history.current = [];
|
|
755
|
+
readingMemory.current.clear();
|
|
756
|
+
setPalette(null);
|
|
757
|
+
setReaderFind({ query: "", cursor: 0, editing: false });
|
|
718
758
|
setData(null);
|
|
719
759
|
setForm(null);
|
|
720
760
|
setPicker(null);
|
|
@@ -749,6 +789,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
749
789
|
if (!replace)
|
|
750
790
|
history.current.push({
|
|
751
791
|
route,
|
|
792
|
+
label: current?.title || labelOf(route),
|
|
752
793
|
index,
|
|
753
794
|
scroll,
|
|
754
795
|
selectedId: selected?.id,
|
|
@@ -799,6 +840,123 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
799
840
|
setError("");
|
|
800
841
|
} else setFocus("nav");
|
|
801
842
|
}
|
|
843
|
+
function readerKey(item) {
|
|
844
|
+
const kind =
|
|
845
|
+
item.entryType === "feedbackReply"
|
|
846
|
+
? "feedbackReply"
|
|
847
|
+
: item.entryType === "note" ||
|
|
848
|
+
route.view === "feedback" ||
|
|
849
|
+
route.tab === "feedback"
|
|
850
|
+
? "feedback"
|
|
851
|
+
: "reply";
|
|
852
|
+
return `${config?.origin}:${user?.id}:${current?.post?.id}:${kind}:${item.id}:${item.revision || ""}`;
|
|
853
|
+
}
|
|
854
|
+
const readerRows = useMemo(
|
|
855
|
+
() =>
|
|
856
|
+
reading
|
|
857
|
+
? markdownRows(
|
|
858
|
+
reading.deleted_at
|
|
859
|
+
? "[deleted — discussion preserved]"
|
|
860
|
+
: reading.body || "",
|
|
861
|
+
contentWidth,
|
|
862
|
+
colors,
|
|
863
|
+
)
|
|
864
|
+
: [],
|
|
865
|
+
[reading, contentWidth, colors],
|
|
866
|
+
);
|
|
867
|
+
const readerHeight = Math.max(1, contentHeight - 2);
|
|
868
|
+
const readerHits = readingMatches(readerRows, readerFind.query);
|
|
869
|
+
useEffect(() => {
|
|
870
|
+
if (reading)
|
|
871
|
+
readingMemory.current.save(
|
|
872
|
+
readerKey(reading),
|
|
873
|
+
Math.min(scroll, Math.max(0, readerRows.length - readerHeight)),
|
|
874
|
+
readerRows,
|
|
875
|
+
);
|
|
876
|
+
}, [reading, scroll, readerRows, readerHeight]);
|
|
877
|
+
function readItem(item) {
|
|
878
|
+
if (!reading) readingReturn.current = scroll;
|
|
879
|
+
const lines = markdownRows(
|
|
880
|
+
item.deleted_at ? "[deleted — discussion preserved]" : item.body || "",
|
|
881
|
+
contentWidth,
|
|
882
|
+
colors,
|
|
883
|
+
);
|
|
884
|
+
setScroll(
|
|
885
|
+
readingMemory.current.restore(readerKey(item), lines, readerHeight),
|
|
886
|
+
);
|
|
887
|
+
setReaderFind({ query: "", cursor: 0, editing: false });
|
|
888
|
+
setReading(item);
|
|
889
|
+
}
|
|
890
|
+
function closeReading() {
|
|
891
|
+
setReading(null);
|
|
892
|
+
setScroll(readingReturn.current);
|
|
893
|
+
setReaderFind({ query: "", cursor: 0, editing: false });
|
|
894
|
+
}
|
|
895
|
+
function moveStep(direction) {
|
|
896
|
+
const fields = fieldsFor(form),
|
|
897
|
+
steps = formSteps(form, fields),
|
|
898
|
+
currentStep = stepAt(steps, field, fields.length);
|
|
899
|
+
const next = currentStep + direction;
|
|
900
|
+
if (direction > 0)
|
|
901
|
+
for (const i of steps[currentStep]?.indexes || []) {
|
|
902
|
+
const f = fields[i],
|
|
903
|
+
value = form.values[f.key] || "";
|
|
904
|
+
if (f.min && value.trim().length < f.min) {
|
|
905
|
+
setField(i);
|
|
906
|
+
setCursor(graphemes(value).length);
|
|
907
|
+
setError(f.label + " needs at least " + f.min + " characters.");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (next >= steps.length) {
|
|
912
|
+
prepare();
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (next < 0) return;
|
|
916
|
+
setField(steps[next].indexes[0]);
|
|
917
|
+
setCursor(
|
|
918
|
+
graphemes(form.values[fields[steps[next].indexes[0]].key] || "").length,
|
|
919
|
+
);
|
|
920
|
+
setError("");
|
|
921
|
+
}
|
|
922
|
+
const activeSteps = form ? formSteps(form, fieldsFor(form)) : [],
|
|
923
|
+
activeStep = form ? stepAt(activeSteps, field, fieldsFor(form).length) : 0;
|
|
924
|
+
const quickItems = palette
|
|
925
|
+
? paletteItems({
|
|
926
|
+
query: palette.query,
|
|
927
|
+
actions,
|
|
928
|
+
history: history.current,
|
|
929
|
+
route,
|
|
930
|
+
})
|
|
931
|
+
: [];
|
|
932
|
+
function chooseQuick(item) {
|
|
933
|
+
if (!item) return;
|
|
934
|
+
setPalette(null);
|
|
935
|
+
if (item.route) {
|
|
936
|
+
navigate(item.route);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (item.id.startsWith("action:")) {
|
|
940
|
+
runAction(item.id.slice(7));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (item.id.startsWith("nav:")) {
|
|
944
|
+
chooseNav(Number(item.id.slice(4)));
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (item.id.startsWith("create:")) {
|
|
948
|
+
const kind = item.id.slice(7);
|
|
949
|
+
if (["project", "event"].includes(kind)) composeCatalog(kind);
|
|
950
|
+
else composePublication(kind);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (item.id === "command") {
|
|
954
|
+
runCommand(item.command);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (item.id === "search")
|
|
958
|
+
navigate({ view: "search", query: item.query, page: 1 });
|
|
959
|
+
}
|
|
802
960
|
async function task(work) {
|
|
803
961
|
if (busy.current) return;
|
|
804
962
|
busy.current = true;
|
|
@@ -1678,8 +1836,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1678
1836
|
return;
|
|
1679
1837
|
}
|
|
1680
1838
|
if (route.view === "thread" && item.entryType === "parent") {
|
|
1681
|
-
|
|
1682
|
-
setScroll(0);
|
|
1839
|
+
readItem(item);
|
|
1683
1840
|
return;
|
|
1684
1841
|
}
|
|
1685
1842
|
if (
|
|
@@ -1695,8 +1852,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1695
1852
|
return;
|
|
1696
1853
|
}
|
|
1697
1854
|
if (route.view === "feedback") {
|
|
1698
|
-
|
|
1699
|
-
setScroll(0);
|
|
1855
|
+
readItem(item);
|
|
1700
1856
|
return;
|
|
1701
1857
|
}
|
|
1702
1858
|
navigate({
|
|
@@ -1864,8 +2020,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1864
2020
|
}
|
|
1865
2021
|
if (id === "read") {
|
|
1866
2022
|
if (current?.post) {
|
|
1867
|
-
|
|
1868
|
-
setScroll(0);
|
|
2023
|
+
readItem(selected);
|
|
1869
2024
|
} else openSelected();
|
|
1870
2025
|
} else if (id === "reply") reply();
|
|
1871
2026
|
else if (id === "comment") lineFeedback();
|
|
@@ -1891,8 +2046,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1891
2046
|
invalidate("Received notifications marked as read.");
|
|
1892
2047
|
});
|
|
1893
2048
|
}
|
|
1894
|
-
async function runCommand() {
|
|
1895
|
-
const value = command.trim();
|
|
2049
|
+
async function runCommand(override) {
|
|
2050
|
+
const value = (override ?? command).trim();
|
|
1896
2051
|
if (!value) return;
|
|
1897
2052
|
setCommand("");
|
|
1898
2053
|
setCommandCursor(0);
|
|
@@ -1924,6 +2079,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1924
2079
|
navigate({ view: parsed.command, id: parsed.arg, page: 1 });
|
|
1925
2080
|
return;
|
|
1926
2081
|
}
|
|
2082
|
+
if (parsed.command === "actions") {
|
|
2083
|
+
setPalette({ query: "", cursor: 0, index: 0 });
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
1927
2086
|
if (parsed.command === "appearance") {
|
|
1928
2087
|
openAppearance();
|
|
1929
2088
|
return;
|
|
@@ -2033,6 +2192,45 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2033
2192
|
reconnect();
|
|
2034
2193
|
return;
|
|
2035
2194
|
}
|
|
2195
|
+
if (palette) {
|
|
2196
|
+
if (keyEvent.escape) {
|
|
2197
|
+
setPalette(null);
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
if (keyEvent.upArrow || keyEvent.downArrow) {
|
|
2201
|
+
setPalette((old) => ({
|
|
2202
|
+
...old,
|
|
2203
|
+
index: Math.max(
|
|
2204
|
+
0,
|
|
2205
|
+
Math.min(
|
|
2206
|
+
quickItems.length - 1,
|
|
2207
|
+
old.index + (keyEvent.downArrow ? 1 : -1),
|
|
2208
|
+
),
|
|
2209
|
+
),
|
|
2210
|
+
}));
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
if (keyEvent.return) {
|
|
2214
|
+
chooseQuick(quickItems[palette.index]);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
const updated = editText(palette.query, palette.cursor, input, keyEvent, {
|
|
2218
|
+
max: 120,
|
|
2219
|
+
});
|
|
2220
|
+
setPalette({ query: updated.value, cursor: updated.cursor, index: 0 });
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (
|
|
2224
|
+
keyEvent.ctrl &&
|
|
2225
|
+
input === "k" &&
|
|
2226
|
+
!form &&
|
|
2227
|
+
!preview &&
|
|
2228
|
+
focus !== "prompt"
|
|
2229
|
+
) {
|
|
2230
|
+
setActionIndex(null);
|
|
2231
|
+
setPalette({ query: "", cursor: 0, index: 0 });
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2036
2234
|
if (actionIndex !== null) {
|
|
2037
2235
|
if (keyEvent.escape || input === "x" || input === "X")
|
|
2038
2236
|
setActionIndex(null);
|
|
@@ -2043,36 +2241,65 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2043
2241
|
return;
|
|
2044
2242
|
}
|
|
2045
2243
|
if (reading) {
|
|
2046
|
-
const
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
)
|
|
2053
|
-
|
|
2244
|
+
const maximum = Math.max(0, readerRows.length - readerHeight);
|
|
2245
|
+
if (readerFind.editing) {
|
|
2246
|
+
if (keyEvent.escape) {
|
|
2247
|
+
setReaderFind((old) => ({ ...old, editing: false }));
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
if (keyEvent.return) {
|
|
2251
|
+
setReaderFind((old) => ({ ...old, editing: false }));
|
|
2252
|
+
if (readerHits.length)
|
|
2253
|
+
setScroll(readerHits.find((n) => n >= scroll) ?? readerHits[0]);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
const updated = editText(
|
|
2257
|
+
readerFind.query,
|
|
2258
|
+
readerFind.cursor,
|
|
2259
|
+
input,
|
|
2260
|
+
keyEvent,
|
|
2261
|
+
{ max: 120 },
|
|
2262
|
+
);
|
|
2263
|
+
setReaderFind({
|
|
2264
|
+
query: updated.value,
|
|
2265
|
+
cursor: updated.cursor,
|
|
2266
|
+
editing: true,
|
|
2267
|
+
});
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
if (input === "/") {
|
|
2271
|
+
setReaderFind((old) => ({ ...old, editing: true }));
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if ((input === "n" || input === "N") && readerFind.query) {
|
|
2275
|
+
setScroll(nextReadingMatch(readerHits, scroll, input === "n" ? 1 : -1));
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2054
2278
|
if (keyEvent.escape || keyEvent.return || input === " ") {
|
|
2055
|
-
|
|
2279
|
+
closeReading();
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
if (keyEvent.home) {
|
|
2056
2283
|
setScroll(0);
|
|
2057
2284
|
return;
|
|
2058
2285
|
}
|
|
2286
|
+
if (keyEvent.end) {
|
|
2287
|
+
setScroll(maximum);
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2059
2290
|
if (keyEvent.upArrow || keyEvent.pageUp)
|
|
2060
|
-
setScroll((v) =>
|
|
2061
|
-
Math.max(0, v - (keyEvent.pageUp ? contentHeight : 1)),
|
|
2062
|
-
);
|
|
2291
|
+
setScroll((v) => Math.max(0, v - (keyEvent.pageUp ? readerHeight : 1)));
|
|
2063
2292
|
else if (keyEvent.downArrow || keyEvent.pageDown)
|
|
2064
2293
|
setScroll((v) =>
|
|
2065
|
-
Math.min(maximum, v + (keyEvent.pageDown ?
|
|
2294
|
+
Math.min(maximum, v + (keyEvent.pageDown ? readerHeight : 1)),
|
|
2066
2295
|
);
|
|
2067
2296
|
else if (input === "r") {
|
|
2068
|
-
|
|
2297
|
+
closeReading();
|
|
2069
2298
|
reply();
|
|
2070
2299
|
} else if (input === "e" || input === "E") {
|
|
2071
|
-
|
|
2300
|
+
closeReading();
|
|
2072
2301
|
editSelected();
|
|
2073
|
-
} else if (input === "d" || input === "D")
|
|
2074
|
-
deleteSelected();
|
|
2075
|
-
}
|
|
2302
|
+
} else if (input === "d" || input === "D") deleteSelected();
|
|
2076
2303
|
return;
|
|
2077
2304
|
}
|
|
2078
2305
|
if (preview) {
|
|
@@ -2124,6 +2351,14 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2124
2351
|
setNotice("Default preview restored. Ctrl+S saves; Esc cancels.");
|
|
2125
2352
|
return;
|
|
2126
2353
|
}
|
|
2354
|
+
if (
|
|
2355
|
+
keyEvent.ctrl &&
|
|
2356
|
+
["n", "p"].includes(input) &&
|
|
2357
|
+
formSteps(form, fieldsFor(form)).length
|
|
2358
|
+
) {
|
|
2359
|
+
moveStep(input === "n" ? 1 : -1);
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2127
2362
|
if (keyEvent.ctrl && input === "e") {
|
|
2128
2363
|
externalEdit();
|
|
2129
2364
|
return;
|
|
@@ -2409,8 +2644,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2409
2644
|
selected &&
|
|
2410
2645
|
["post", "thread", "feedback"].includes(route.view)
|
|
2411
2646
|
) {
|
|
2412
|
-
|
|
2413
|
-
setScroll(0);
|
|
2647
|
+
readItem(selected);
|
|
2414
2648
|
return;
|
|
2415
2649
|
}
|
|
2416
2650
|
if (keyEvent.return) {
|
|
@@ -2509,7 +2743,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2509
2743
|
? "Saving…"
|
|
2510
2744
|
: loading
|
|
2511
2745
|
? "Loading…"
|
|
2512
|
-
: "
|
|
2746
|
+
: "Ctrl+K quick actions · Shared account · same permissions");
|
|
2513
2747
|
const spin =
|
|
2514
2748
|
effectiveAppearance.motion === "reduced" ? "·" : ["◐", "◓", "◑", "◒"][tick];
|
|
2515
2749
|
let center;
|
|
@@ -2652,6 +2886,60 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2652
2886
|
animate: true,
|
|
2653
2887
|
}),
|
|
2654
2888
|
);
|
|
2889
|
+
} else if (palette) {
|
|
2890
|
+
const count = Math.max(1, bodyHeight - 5),
|
|
2891
|
+
first = Math.max(0, palette.index - count + 1);
|
|
2892
|
+
center = h(
|
|
2893
|
+
Box,
|
|
2894
|
+
{
|
|
2895
|
+
borderStyle: colors.frame,
|
|
2896
|
+
borderColor: colors.purple,
|
|
2897
|
+
backgroundColor: colors.panel,
|
|
2898
|
+
flexDirection: "column",
|
|
2899
|
+
paddingX: 1,
|
|
2900
|
+
height: bodyHeight,
|
|
2901
|
+
overflow: "hidden",
|
|
2902
|
+
},
|
|
2903
|
+
h(TextLine, {
|
|
2904
|
+
text: "QUICK ACTIONS · type to filter",
|
|
2905
|
+
width: width - 6,
|
|
2906
|
+
color: colors.purple,
|
|
2907
|
+
bold: true,
|
|
2908
|
+
}),
|
|
2909
|
+
h(InputText, {
|
|
2910
|
+
value: palette.query,
|
|
2911
|
+
cursor: palette.cursor,
|
|
2912
|
+
width: width - 6,
|
|
2913
|
+
}),
|
|
2914
|
+
h(
|
|
2915
|
+
Box,
|
|
2916
|
+
{ flexDirection: "column", height: count, overflow: "hidden" },
|
|
2917
|
+
...quickItems.slice(first, first + count).map((item, i) =>
|
|
2918
|
+
h(
|
|
2919
|
+
Text,
|
|
2920
|
+
{
|
|
2921
|
+
key: item.id,
|
|
2922
|
+
wrap: "truncate",
|
|
2923
|
+
color: first + i === palette.index ? colors.blue : colors.text,
|
|
2924
|
+
backgroundColor:
|
|
2925
|
+
first + i === palette.index ? colors.selected : undefined,
|
|
2926
|
+
},
|
|
2927
|
+
clip(
|
|
2928
|
+
(first + i === palette.index ? "› " : " ") +
|
|
2929
|
+
item.label +
|
|
2930
|
+
" · " +
|
|
2931
|
+
item.group,
|
|
2932
|
+
width - 6,
|
|
2933
|
+
),
|
|
2934
|
+
),
|
|
2935
|
+
),
|
|
2936
|
+
),
|
|
2937
|
+
h(TextLine, {
|
|
2938
|
+
text: "↑↓ choose · Enter open · Esc return · No action runs while typing",
|
|
2939
|
+
width: width - 6,
|
|
2940
|
+
color: colors.muted,
|
|
2941
|
+
}),
|
|
2942
|
+
);
|
|
2655
2943
|
} else if (dialog) {
|
|
2656
2944
|
center = h(
|
|
2657
2945
|
Box,
|
|
@@ -2691,19 +2979,41 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
2691
2979
|
} else {
|
|
2692
2980
|
let body;
|
|
2693
2981
|
if (reading) {
|
|
2694
|
-
const
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2982
|
+
const position = Math.min(
|
|
2983
|
+
scroll,
|
|
2984
|
+
Math.max(0, readerRows.length - readerHeight),
|
|
2985
|
+
);
|
|
2986
|
+
const matches = new Set(readerHits);
|
|
2987
|
+
body = h(
|
|
2988
|
+
Box,
|
|
2989
|
+
{ flexDirection: "column", height: contentHeight, overflow: "hidden" },
|
|
2990
|
+
readerFind.editing
|
|
2991
|
+
? h(InputText, {
|
|
2992
|
+
value: readerFind.query,
|
|
2993
|
+
cursor: readerFind.cursor,
|
|
2994
|
+
width: contentWidth,
|
|
2995
|
+
})
|
|
2996
|
+
: h(TextLine, {
|
|
2997
|
+
text: readerFind.query
|
|
2998
|
+
? `Find: ${readerFind.query} · ${readerHits.length} matching lines · n next / N previous`
|
|
2999
|
+
: `Reply #${reading.id} · ${stamp(reading.created)} · / find in text`,
|
|
3000
|
+
width: contentWidth,
|
|
3001
|
+
color: colors.muted,
|
|
3002
|
+
}),
|
|
3003
|
+
h(FrameRows, {
|
|
3004
|
+
rows: readerRows.map((row, i) =>
|
|
3005
|
+
matches.has(i) ? { ...row, bold: true, color: colors.blue } : row,
|
|
3006
|
+
),
|
|
3007
|
+
width: contentWidth,
|
|
3008
|
+
height: readerHeight,
|
|
3009
|
+
offset: position,
|
|
3010
|
+
}),
|
|
3011
|
+
h(TextLine, {
|
|
3012
|
+
text: `Lines ${Math.min(position + 1, readerRows.length)}–${Math.min(position + readerHeight, readerRows.length)} of ${readerRows.length} · Home / End · Esc returns`,
|
|
3013
|
+
width: contentWidth,
|
|
3014
|
+
color: colors.muted,
|
|
3015
|
+
}),
|
|
2700
3016
|
);
|
|
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
3017
|
} else if (preview) {
|
|
2708
3018
|
const payload = preview.payload,
|
|
2709
3019
|
audience =
|
|
@@ -3021,7 +3331,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
3021
3331
|
text: form
|
|
3022
3332
|
? (form.kind === "appearance"
|
|
3023
3333
|
? "Local preferences · Preview now · Ctrl+S save · Esc cancel"
|
|
3024
|
-
:
|
|
3334
|
+
: (activeSteps.length
|
|
3335
|
+
? `Step ${activeStep + 1}/${activeSteps.length} · ${activeSteps[activeStep].label} · `
|
|
3336
|
+
: "") + "Tab field · Ctrl+N/P steps · Ctrl+S preview") +
|
|
3025
3337
|
(draftSaved ? " · Draft saved locally" : "")
|
|
3026
3338
|
: preview
|
|
3027
3339
|
? "Scroll to review · ← → choose · Enter confirms"
|
|
@@ -3096,8 +3408,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
3096
3408
|
compact && focus === "nav" ? null : pane,
|
|
3097
3409
|
);
|
|
3098
3410
|
}
|
|
3099
|
-
const hint =
|
|
3100
|
-
|
|
3411
|
+
const hint = palette
|
|
3412
|
+
? "Type to filter · ↑↓ choose · Enter open · Esc return"
|
|
3413
|
+
: ["projects", "events"].includes(route.view) &&
|
|
3414
|
+
!form &&
|
|
3415
|
+
!preview &&
|
|
3416
|
+
!picker
|
|
3101
3417
|
? "↑↓ select · Enter open · F filters · N new · [ ] pages · / search"
|
|
3102
3418
|
: ["project", "event"].includes(route.view) && !form && !preview
|
|
3103
3419
|
? "↑↓ scroll · X actions · E edit your publication · Esc back"
|
|
@@ -3110,11 +3426,13 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
3110
3426
|
: picker
|
|
3111
3427
|
? "Project files stay local until publication is confirmed"
|
|
3112
3428
|
: reading
|
|
3113
|
-
?
|
|
3429
|
+
? readerFind.editing
|
|
3430
|
+
? "Find in this text · Enter locate · Esc finish search"
|
|
3431
|
+
: "↑↓ scroll · / find · n/N match · Home/End · Esc return"
|
|
3114
3432
|
: preview
|
|
3115
3433
|
? "← → choose · Enter confirm · Esc edit"
|
|
3116
3434
|
: form
|
|
3117
|
-
? "Tab field · Ctrl+
|
|
3435
|
+
? "Tab field · Ctrl+N/P steps · Ctrl+E editor · Ctrl+S preview"
|
|
3118
3436
|
: phase !== "ready"
|
|
3119
3437
|
? "Enter connect / open browser · Ctrl+C exit"
|
|
3120
3438
|
: 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
|
+
}
|