saterminal 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/state.ts +45 -11
- package/src/tui/app.ts +38 -17
- package/src/tui/render.ts +21 -3
- package/src/tui/types.ts +1 -1
- package/test/state.test.ts +47 -0
package/package.json
CHANGED
package/src/state.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
3
4
|
import { defaultFocus, normalizeFocus } from "./focus.ts";
|
|
4
5
|
import type { Attempt, Focus, Outcome, SummaryRow } from "./types.ts";
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
+
export function resolveStateDir(home = homedir()): string {
|
|
8
|
+
return join(home, ".saterminal", "userlocal");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function displayStateDir(dir: string, home = homedir()): string {
|
|
12
|
+
if (dir === home) {
|
|
13
|
+
return "~";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const prefix = `${home}${sep}`;
|
|
17
|
+
if (dir.startsWith(prefix)) {
|
|
18
|
+
return `~${dir.slice(home.length)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const stateDir = resolveStateDir();
|
|
7
25
|
export const attemptsPath = join(stateDir, "attempts.csv");
|
|
8
26
|
export const summaryPath = join(stateDir, "summary.csv");
|
|
9
27
|
export const focusPath = join(stateDir, "focus.json");
|
|
@@ -11,11 +29,23 @@ export const focusPath = join(stateDir, "focus.json");
|
|
|
11
29
|
const attemptsHeader = "question_id,outcome,updated_at,elapsed_seconds";
|
|
12
30
|
const summaryHeader = "metric,value,updated_at";
|
|
13
31
|
|
|
14
|
-
export async function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
export async function stateDirExists(dir = stateDir): Promise<boolean> {
|
|
33
|
+
try {
|
|
34
|
+
return (await stat(dir)).isDirectory();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function ensureStateFiles(dir = stateDir): Promise<void> {
|
|
45
|
+
await mkdir(dir, { recursive: true });
|
|
46
|
+
await ensureFile(join(dir, "attempts.csv"), `${attemptsHeader}\n`);
|
|
47
|
+
await ensureFile(join(dir, "summary.csv"), `${summaryHeader}\n`);
|
|
48
|
+
await ensureFile(join(dir, "focus.json"), `${JSON.stringify(defaultFocus, null, 2)}\n`);
|
|
19
49
|
}
|
|
20
50
|
|
|
21
51
|
export async function loadAttempts(path = attemptsPath): Promise<Map<string, Attempt>> {
|
|
@@ -107,11 +137,15 @@ export async function saveSummary(attempts: Map<string, Attempt>, path = summary
|
|
|
107
137
|
|
|
108
138
|
export async function loadFocus(path = focusPath): Promise<Focus> {
|
|
109
139
|
await ensureFile(path, `${JSON.stringify(defaultFocus, null, 2)}\n`);
|
|
140
|
+
const raw = await readFile(path, "utf8");
|
|
141
|
+
let parsed: unknown;
|
|
110
142
|
try {
|
|
111
|
-
|
|
112
|
-
} catch {
|
|
113
|
-
|
|
143
|
+
parsed = JSON.parse(raw);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new Error(`Invalid focus file at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
114
146
|
}
|
|
147
|
+
|
|
148
|
+
return normalizeFocus(parsed);
|
|
115
149
|
}
|
|
116
150
|
|
|
117
151
|
export async function saveFocus(focus: Focus, path = focusPath): Promise<void> {
|
package/src/tui/app.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { defaultFocus } from "../focus.ts";
|
|
2
|
-
import { ensureStateFiles, loadAttempts, loadFocus } from "../state.ts";
|
|
2
|
+
import { ensureStateFiles, loadAttempts, loadFocus, stateDirExists } from "../state.ts";
|
|
3
3
|
import { handleKey } from "./input.ts";
|
|
4
4
|
import { createDocument, render } from "./render.ts";
|
|
5
5
|
import { term } from "./terminal.ts";
|
|
6
6
|
import type { AppState, KeyData } from "./types.ts";
|
|
7
7
|
|
|
8
|
+
async function loadPersistedState(state: AppState): Promise<void> {
|
|
9
|
+
await ensureStateFiles();
|
|
10
|
+
state.attempts = await loadAttempts();
|
|
11
|
+
state.focus = await loadFocus();
|
|
12
|
+
state.focusIndex = 1;
|
|
13
|
+
state.focusColumn = 0;
|
|
14
|
+
state.focusRow = 1;
|
|
15
|
+
state.view = "focus";
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
export async function runTui(): Promise<void> {
|
|
9
19
|
const state: AppState = {
|
|
10
20
|
attempts: new Map(),
|
|
@@ -13,7 +23,7 @@ export async function runTui(): Promise<void> {
|
|
|
13
23
|
focusIndex: 1,
|
|
14
24
|
focusColumn: 0,
|
|
15
25
|
focusRow: 1,
|
|
16
|
-
view: "
|
|
26
|
+
view: "loading",
|
|
17
27
|
selected: 0,
|
|
18
28
|
questionScroll: 0,
|
|
19
29
|
answerScroll: 0,
|
|
@@ -44,6 +54,20 @@ export async function runTui(): Promise<void> {
|
|
|
44
54
|
|
|
45
55
|
process.on("SIGINT", cleanup);
|
|
46
56
|
term.on("resize", () => render(doc, state));
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (await stateDirExists()) {
|
|
60
|
+
await loadPersistedState(state);
|
|
61
|
+
} else {
|
|
62
|
+
state.view = "setup";
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
state.view = "error";
|
|
66
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
render(doc, state);
|
|
70
|
+
|
|
47
71
|
term.on("key", async (name: string, _matches?: string[], data?: KeyData) => {
|
|
48
72
|
try {
|
|
49
73
|
if (name === "CTRL_C" || name === "q") {
|
|
@@ -51,6 +75,18 @@ export async function runTui(): Promise<void> {
|
|
|
51
75
|
return;
|
|
52
76
|
}
|
|
53
77
|
|
|
78
|
+
if (state.view === "setup") {
|
|
79
|
+
if (name === "y" || name === "ENTER") {
|
|
80
|
+
await loadPersistedState(state);
|
|
81
|
+
} else if (name === "n") {
|
|
82
|
+
cleanup();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(doc, state);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
54
90
|
await handleKey(state, name, data);
|
|
55
91
|
render(doc, state);
|
|
56
92
|
} catch (error) {
|
|
@@ -59,19 +95,4 @@ export async function runTui(): Promise<void> {
|
|
|
59
95
|
render(doc, state);
|
|
60
96
|
}
|
|
61
97
|
});
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
await ensureStateFiles();
|
|
65
|
-
state.attempts = await loadAttempts();
|
|
66
|
-
state.focus = await loadFocus();
|
|
67
|
-
state.focusIndex = 1;
|
|
68
|
-
state.focusColumn = 0;
|
|
69
|
-
state.focusRow = 1;
|
|
70
|
-
state.view = "focus";
|
|
71
|
-
} catch (error) {
|
|
72
|
-
state.view = "error";
|
|
73
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
render(doc, state);
|
|
77
98
|
}
|
package/src/tui/render.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildSummaryRows } from "../state.ts";
|
|
1
|
+
import { buildSummaryRows, displayStateDir, stateDir } from "../state.ts";
|
|
2
2
|
import { focusSummary } from "../focus.ts";
|
|
3
3
|
import { htmlToText, wrapText } from "../text.ts";
|
|
4
4
|
import type { TextSegment } from "../text.ts";
|
|
@@ -41,7 +41,9 @@ export function render(doc: TkDocument, state: AppState): void {
|
|
|
41
41
|
header(doc, state);
|
|
42
42
|
|
|
43
43
|
if (state.view === "loading") {
|
|
44
|
-
|
|
44
|
+
renderLoading(doc);
|
|
45
|
+
} else if (state.view === "setup") {
|
|
46
|
+
renderSetup(doc);
|
|
45
47
|
} else if (state.view === "focus") {
|
|
46
48
|
renderFocus(doc, state);
|
|
47
49
|
} else if (state.view === "practice") {
|
|
@@ -62,6 +64,20 @@ export function render(doc: TkDocument, state: AppState): void {
|
|
|
62
64
|
doc.draw();
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
function renderLoading(doc: TkDocument): void {
|
|
68
|
+
text(doc, 0, 3, "Loading...");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderSetup(doc: TkDocument): void {
|
|
72
|
+
const { width } = terminalSize();
|
|
73
|
+
const location = displayStateDir(stateDir);
|
|
74
|
+
|
|
75
|
+
text(doc, 0, 3, "storage location", { bold: true });
|
|
76
|
+
text(doc, 0, 5, "Sat saves progress, focus settings, and summary stats locally.", { color: "gray" }, width);
|
|
77
|
+
text(doc, 0, 7, "Allow creating this directory?", { bold: true }, width);
|
|
78
|
+
text(doc, 0, 9, location, { color: "cyan" }, width);
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
function renderPaused(doc: TkDocument): void {
|
|
66
82
|
const { width, height } = terminalSize();
|
|
67
83
|
const label = "PAUSED";
|
|
@@ -79,7 +95,9 @@ function header(doc: TkDocument, state: AppState): void {
|
|
|
79
95
|
|
|
80
96
|
function footer(doc: TkDocument, state: AppState): void {
|
|
81
97
|
const { width, height } = terminalSize();
|
|
82
|
-
const controls = state.view === "
|
|
98
|
+
const controls = state.view === "setup"
|
|
99
|
+
? "y allow | n decline | q quit"
|
|
100
|
+
: state.view === "practice"
|
|
83
101
|
? practiceControls(state)
|
|
84
102
|
: state.view === "focus"
|
|
85
103
|
? "up/down or j/k row | tab/shift-tab group | space toggle | enter start | q quit"
|
package/src/tui/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Attempt, Focus, PracticeQuestion } from "../types.ts";
|
|
|
2
2
|
import type { TextSegment } from "../text.ts";
|
|
3
3
|
import type { PaneId } from "./viewport.ts";
|
|
4
4
|
|
|
5
|
-
export type View = "focus" | "loading" | "practice" | "review" | "history" | "summary" | "detail" | "error";
|
|
5
|
+
export type View = "setup" | "focus" | "loading" | "practice" | "review" | "history" | "summary" | "detail" | "error";
|
|
6
6
|
|
|
7
7
|
export type AppState = {
|
|
8
8
|
attempts: Map<string, Attempt>;
|
package/test/state.test.ts
CHANGED
|
@@ -5,15 +5,38 @@ import { join } from "node:path";
|
|
|
5
5
|
import { defaultFocus, normalizeFocus } from "../src/focus.ts";
|
|
6
6
|
import {
|
|
7
7
|
buildSummaryRows,
|
|
8
|
+
displayStateDir,
|
|
8
9
|
loadAttempts,
|
|
9
10
|
loadFocus,
|
|
10
11
|
nextOutcome,
|
|
11
12
|
recordAttempt,
|
|
13
|
+
resolveStateDir,
|
|
12
14
|
saveAttempts,
|
|
13
15
|
saveFocus,
|
|
16
|
+
stateDirExists,
|
|
14
17
|
} from "../src/state.ts";
|
|
15
18
|
|
|
16
19
|
describe("state", () => {
|
|
20
|
+
test("resolves state dir under the user home directory", () => {
|
|
21
|
+
expect(resolveStateDir("/home/user")).toBe("/home/user/.saterminal/userlocal");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("displays state dir with a tilde prefix", () => {
|
|
25
|
+
expect(displayStateDir("/home/user/.saterminal/userlocal", "/home/user")).toBe("~/.saterminal/userlocal");
|
|
26
|
+
expect(displayStateDir("/home/user", "/home/user")).toBe("~");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("detects whether the state directory exists", async () => {
|
|
30
|
+
const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
expect(await stateDirExists(dir)).toBe(true);
|
|
34
|
+
expect(await stateDirExists(join(dir, "missing"))).toBe(false);
|
|
35
|
+
} finally {
|
|
36
|
+
await rm(dir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
17
40
|
test("creates and reads a compact attempts csv", async () => {
|
|
18
41
|
const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
|
|
19
42
|
const path = join(dir, "attempts.csv");
|
|
@@ -103,6 +126,18 @@ describe("state", () => {
|
|
|
103
126
|
});
|
|
104
127
|
});
|
|
105
128
|
|
|
129
|
+
test("creates default focus when file is missing", async () => {
|
|
130
|
+
const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
|
|
131
|
+
const path = join(dir, "focus.json");
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
expect(await loadFocus(path)).toEqual(defaultFocus);
|
|
135
|
+
expect(await readFile(path, "utf8")).toContain("\"difficulties\"");
|
|
136
|
+
} finally {
|
|
137
|
+
await rm(dir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
106
141
|
test("saves and loads focus json", async () => {
|
|
107
142
|
const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
|
|
108
143
|
const path = join(dir, "focus.json");
|
|
@@ -117,4 +152,16 @@ describe("state", () => {
|
|
|
117
152
|
await rm(dir, { recursive: true, force: true });
|
|
118
153
|
}
|
|
119
154
|
});
|
|
155
|
+
|
|
156
|
+
test("throws when focus json is invalid", async () => {
|
|
157
|
+
const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
|
|
158
|
+
const path = join(dir, "focus.json");
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await writeFile(path, "{not json", "utf8");
|
|
162
|
+
await expect(loadFocus(path)).rejects.toThrow(/Invalid focus file/);
|
|
163
|
+
} finally {
|
|
164
|
+
await rm(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
120
167
|
});
|