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 CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "saterminal",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
- "sat": "./src/main.ts"
6
+ "sat": "src/main.ts"
7
7
  },
8
8
  "scripts": {
9
9
  "dev": "bun run src/main.ts",
package/src/state.ts CHANGED
@@ -1,9 +1,27 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
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 const stateDir = "userlocal";
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 ensureStateFiles(): Promise<void> {
15
- await mkdir(stateDir, { recursive: true });
16
- await ensureFile(attemptsPath, `${attemptsHeader}\n`);
17
- await ensureFile(summaryPath, `${summaryHeader}\n`);
18
- await ensureFile(focusPath, `${JSON.stringify(defaultFocus, null, 2)}\n`);
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
- return normalizeFocus(JSON.parse(await readFile(path, "utf8")));
112
- } catch {
113
- return defaultFocus;
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: "focus",
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
- text(doc, 0, 3, "Loading...");
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 === "practice"
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>;
@@ -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
  });