@bakapiano/ccsm 0.14.0 → 0.15.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/CLAUDE.md +474 -475
- package/README.md +189 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliActivity.js +118 -0
- package/lib/codexSeed.js +147 -0
- package/lib/config.js +211 -188
- package/lib/folders.js +105 -105
- package/lib/localCliSessions.js +489 -489
- package/lib/persistedSessions.js +144 -142
- package/lib/webTerminal.js +224 -224
- package/lib/workspace.js +230 -230
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +613 -608
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1628 -1628
- package/public/index.html +111 -105
- package/public/js/api.js +296 -280
- package/public/js/components/AdoptModal.js +343 -343
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +141 -141
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +299 -299
- package/public/js/components/TerminalView.js +314 -314
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +132 -132
- package/public/js/pages/AboutPage.js +165 -165
- package/public/js/pages/ConfigurePage.js +505 -475
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +101 -97
- package/public/js/state.js +231 -231
- package/scripts/dev.js +44 -11
- package/scripts/install.js +158 -158
- package/scripts/restart-helper.js +91 -0
- package/server.js +1278 -1254
- package/lib/cliSessionWatcher.js +0 -275
- package/public/manifest.webmanifest +0 -15
package/lib/config.js
CHANGED
|
@@ -1,188 +1,211 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs/promises');
|
|
4
|
-
const fsSync = require('node:fs');
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
const os = require('node:os');
|
|
7
|
-
const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
8
|
-
|
|
9
|
-
// Data dir lives under ~/.ccsm by default so config survives across upgrades
|
|
10
|
-
// (incl. running from a new npx checkout). Override with CCSM_HOME if you
|
|
11
|
-
// want a different location.
|
|
12
|
-
const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
|
|
13
|
-
const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
|
|
14
|
-
|
|
15
|
-
const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
|
|
16
|
-
|
|
17
|
-
// v1.0 — wt / system-terminal launch path removed. Sessions are always
|
|
18
|
-
// in-page web terminals managed by ccsm. CLI is pluggable: configure one
|
|
19
|
-
// or more entries under `clis` (claude, codex, custom wrappers), pick a
|
|
20
|
-
// default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
|
|
21
|
-
// `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
|
|
22
|
-
// `snapshot*`) are silently dropped on load.
|
|
23
|
-
const DEFAULT_CLIS = [
|
|
24
|
-
{
|
|
25
|
-
id: 'claude',
|
|
26
|
-
name: 'Claude Code',
|
|
27
|
-
command: 'claude',
|
|
28
|
-
args: [],
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
id
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
delete out.
|
|
111
|
-
delete out.
|
|
112
|
-
delete out.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const fsSync = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
8
|
+
|
|
9
|
+
// Data dir lives under ~/.ccsm by default so config survives across upgrades
|
|
10
|
+
// (incl. running from a new npx checkout). Override with CCSM_HOME if you
|
|
11
|
+
// want a different location.
|
|
12
|
+
const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
|
|
13
|
+
const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
|
|
14
|
+
|
|
15
|
+
const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
|
|
16
|
+
|
|
17
|
+
// v1.0 — wt / system-terminal launch path removed. Sessions are always
|
|
18
|
+
// in-page web terminals managed by ccsm. CLI is pluggable: configure one
|
|
19
|
+
// or more entries under `clis` (claude, codex, custom wrappers), pick a
|
|
20
|
+
// default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
|
|
21
|
+
// `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
|
|
22
|
+
// `snapshot*`) are silently dropped on load.
|
|
23
|
+
const DEFAULT_CLIS = [
|
|
24
|
+
{
|
|
25
|
+
id: 'claude',
|
|
26
|
+
name: 'Claude Code',
|
|
27
|
+
command: 'claude',
|
|
28
|
+
args: [],
|
|
29
|
+
// Pre-assign the upstream session UUID when starting a NEW session.
|
|
30
|
+
// ccsm picks the UUID, passes it via these args, and stores it on
|
|
31
|
+
// the record immediately — no need to poll the transcript dir later.
|
|
32
|
+
newSessionIdArgs: ['--session-id', '<id>'],
|
|
33
|
+
resumeIdArgs: ['--resume', '<id>'],
|
|
34
|
+
shell: 'direct',
|
|
35
|
+
type: 'claude',
|
|
36
|
+
builtin: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'codex',
|
|
40
|
+
name: 'OpenAI Codex',
|
|
41
|
+
command: 'codex',
|
|
42
|
+
args: [],
|
|
43
|
+
// codex has no "use this UUID for a new session" flag, but we work
|
|
44
|
+
// around it: ccsm seeds a fake rollout file at the right path with
|
|
45
|
+
// our chosen UUID (lib/codexSeed.js), then spawns `codex resume <id>`
|
|
46
|
+
// so the first launch *is* a resume against our seed. From then on
|
|
47
|
+
// codex appends to the same file and resume-by-id keeps working.
|
|
48
|
+
newSessionIdArgs: ['resume', '<id>'],
|
|
49
|
+
resumeIdArgs: ['resume', '<id>'],
|
|
50
|
+
shell: 'direct',
|
|
51
|
+
type: 'codex',
|
|
52
|
+
builtin: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'copilot',
|
|
56
|
+
name: 'GitHub Copilot',
|
|
57
|
+
command: 'copilot',
|
|
58
|
+
args: [],
|
|
59
|
+
newSessionIdArgs: ['--session-id', '<id>'],
|
|
60
|
+
resumeIdArgs: ['--resume', '<id>'],
|
|
61
|
+
shell: 'direct',
|
|
62
|
+
type: 'copilot',
|
|
63
|
+
builtin: true,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const DEFAULTS = {
|
|
68
|
+
port: 7777,
|
|
69
|
+
workDir: path.join(os.homedir(), 'ccsm-workspaces'),
|
|
70
|
+
// Repos available for cloning into a fresh workspace at launch time.
|
|
71
|
+
// { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
|
|
72
|
+
repos: [],
|
|
73
|
+
// Pluggable CLIs. Add wrappers like `ccp` (gc2cc) or self-hosted
|
|
74
|
+
// proxies by appending an entry. defaultCliId picks one for the
|
|
75
|
+
// Launch button when the user doesn't override.
|
|
76
|
+
clis: DEFAULT_CLIS,
|
|
77
|
+
defaultCliId: 'claude',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function ensureDataDir() {
|
|
81
|
+
if (!fsSync.existsSync(DATA_DIR)) {
|
|
82
|
+
fsSync.mkdirSync(DATA_DIR, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If we find a legacy <repo>/data dir from before the home-dir move AND
|
|
87
|
+
// no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
|
|
88
|
+
// empty so existing users with both dirs aren't clobbered.
|
|
89
|
+
function migrateLegacyDataIfNeeded() {
|
|
90
|
+
if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
|
|
91
|
+
if (LEGACY_DATA_DIR === DATA_DIR) return;
|
|
92
|
+
ensureDataDir();
|
|
93
|
+
const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
|
|
94
|
+
if (!dataEmpty) return;
|
|
95
|
+
try {
|
|
96
|
+
fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
|
|
97
|
+
console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.error('[ccsm] legacy migration failed:', e.message);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
migrateLegacyDataIfNeeded();
|
|
104
|
+
|
|
105
|
+
// Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
|
|
106
|
+
// object so callers don't mutate DEFAULTS.
|
|
107
|
+
function mergeWithDefaults(partial) {
|
|
108
|
+
const out = { ...DEFAULTS, ...partial };
|
|
109
|
+
// Drop v0.x keys that the new architecture doesn't use.
|
|
110
|
+
delete out.terminal;
|
|
111
|
+
delete out.commandShell;
|
|
112
|
+
delete out.claudeCommand;
|
|
113
|
+
delete out.defaultTerminalMode;
|
|
114
|
+
delete out.autoFocusOnLaunch;
|
|
115
|
+
delete out.focusMovesToCenter;
|
|
116
|
+
delete out.snapshotIntervalMs;
|
|
117
|
+
delete out.snapshotHistoryKeep;
|
|
118
|
+
delete out.autoOpenBrowser;
|
|
119
|
+
delete out.browserMode;
|
|
120
|
+
delete out.finderPrompt;
|
|
121
|
+
|
|
122
|
+
if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
|
|
123
|
+
if (!Array.isArray(out.clis)) out.clis = [];
|
|
124
|
+
// Always inject builtin CLIs (claude, codex) if they're missing or were
|
|
125
|
+
// deleted from a saved config — they're managed by ccsm, the user can
|
|
126
|
+
// tweak args/shell but can't remove them. Preserves any user
|
|
127
|
+
// customisation on existing builtin entries.
|
|
128
|
+
for (const def of DEFAULT_CLIS) {
|
|
129
|
+
const existing = out.clis.find((c) => c.id === def.id);
|
|
130
|
+
if (existing) {
|
|
131
|
+
existing.builtin = true;
|
|
132
|
+
// Backfill defaults from the built-in template for any field the
|
|
133
|
+
// user's saved copy is missing — keeps old configs aligned with new
|
|
134
|
+
// schema additions (resumeIdArgs, type, etc.) without clobbering
|
|
135
|
+
// the user's customisations.
|
|
136
|
+
if (existing.resumeIdArgs == null) existing.resumeIdArgs = def.resumeIdArgs;
|
|
137
|
+
if (existing.newSessionIdArgs == null) existing.newSessionIdArgs = def.newSessionIdArgs;
|
|
138
|
+
// Drop the v0.x `resumeArgs` fallback — all builtins now have
|
|
139
|
+
// pre-assigned UUIDs (claude/copilot via flag, codex via seed) so
|
|
140
|
+
// resumeIdArgs always applies on resume.
|
|
141
|
+
delete existing.resumeArgs;
|
|
142
|
+
// Special-case codex: an unreleased earlier iteration of this
|
|
143
|
+
// schema shipped `newSessionIdArgs: []` for codex. The seeded-file
|
|
144
|
+
// trick (lib/codexSeed) now lets us pre-assign, so backfill the
|
|
145
|
+
// ['resume','<id>'] template over an empty array too.
|
|
146
|
+
if (existing.id === 'codex'
|
|
147
|
+
&& Array.isArray(existing.newSessionIdArgs)
|
|
148
|
+
&& existing.newSessionIdArgs.length === 0
|
|
149
|
+
&& Array.isArray(def.newSessionIdArgs)
|
|
150
|
+
&& def.newSessionIdArgs.length > 0) {
|
|
151
|
+
existing.newSessionIdArgs = def.newSessionIdArgs;
|
|
152
|
+
}
|
|
153
|
+
if (!existing.type) existing.type = def.type;
|
|
154
|
+
} else {
|
|
155
|
+
out.clis.unshift({ ...def });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Normalize per-CLI fields.
|
|
159
|
+
out.clis = out.clis.map((c) => {
|
|
160
|
+
const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
|
|
161
|
+
return {
|
|
162
|
+
...rest,
|
|
163
|
+
args: Array.isArray(rest.args) ? rest.args : [],
|
|
164
|
+
resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
|
|
165
|
+
newSessionIdArgs: Array.isArray(rest.newSessionIdArgs) ? rest.newSessionIdArgs : [],
|
|
166
|
+
shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
|
|
167
|
+
type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
|
|
168
|
+
builtin: !!rest.builtin,
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
// Make sure defaultCliId points at an actual CLI; fall back to first.
|
|
172
|
+
if (!out.clis.find((c) => c.id === out.defaultCliId)) {
|
|
173
|
+
out.defaultCliId = out.clis[0].id;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function loadConfig() {
|
|
179
|
+
ensureDataDir();
|
|
180
|
+
try {
|
|
181
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
182
|
+
return mergeWithDefaults(JSON.parse(raw));
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (e.code === 'ENOENT') {
|
|
185
|
+
const cfg = { ...DEFAULTS };
|
|
186
|
+
await atomicWriteJson(CONFIG_PATH, cfg);
|
|
187
|
+
return cfg;
|
|
188
|
+
}
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function saveConfig(partial) {
|
|
194
|
+
ensureDataDir();
|
|
195
|
+
return withFileLock(CONFIG_PATH, async () => {
|
|
196
|
+
const current = await loadConfig();
|
|
197
|
+
const next = mergeWithDefaults({ ...current, ...partial });
|
|
198
|
+
await atomicWriteJson(CONFIG_PATH, next);
|
|
199
|
+
return next;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
loadConfig,
|
|
205
|
+
saveConfig,
|
|
206
|
+
DATA_DIR,
|
|
207
|
+
CONFIG_PATH,
|
|
208
|
+
LEGACY_DATA_DIR,
|
|
209
|
+
DEFAULTS,
|
|
210
|
+
DEFAULT_CLIS,
|
|
211
|
+
};
|
package/lib/folders.js
CHANGED
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// User-curated folders. Sessions reference these by id. Order is
|
|
4
|
-
// user-controlled (drag-reorder in sidebar). The store is a flat list
|
|
5
|
-
// in $DATA_DIR/folders.json:
|
|
6
|
-
// [{ id, name, order, createdAt }]
|
|
7
|
-
//
|
|
8
|
-
// Top-level "Unsorted" is implicit — sessions with folderId === null
|
|
9
|
-
// render under it. The user can't delete or rename it; we just synthesise
|
|
10
|
-
// the bucket in the frontend.
|
|
11
|
-
|
|
12
|
-
const path = require('node:path');
|
|
13
|
-
const fs = require('node:fs/promises');
|
|
14
|
-
const { DATA_DIR } = require('./config');
|
|
15
|
-
const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
16
|
-
|
|
17
|
-
const FILE = path.join(DATA_DIR, 'folders.json');
|
|
18
|
-
|
|
19
|
-
async function loadAll() {
|
|
20
|
-
try {
|
|
21
|
-
const raw = await fs.readFile(FILE, 'utf8');
|
|
22
|
-
const j = JSON.parse(raw);
|
|
23
|
-
return Array.isArray(j) ? j : [];
|
|
24
|
-
} catch (e) {
|
|
25
|
-
if (e.code === 'ENOENT') return [];
|
|
26
|
-
throw e;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function saveAll(list) {
|
|
31
|
-
await atomicWriteJson(FILE, list);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function genId() {
|
|
35
|
-
return 'folder-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function create({ name }) {
|
|
39
|
-
if (!name || typeof name !== 'string') throw new Error('name required');
|
|
40
|
-
return withFileLock(FILE, async () => {
|
|
41
|
-
const list = await loadAll();
|
|
42
|
-
const entry = {
|
|
43
|
-
id: genId(),
|
|
44
|
-
name: name.trim(),
|
|
45
|
-
order: list.length,
|
|
46
|
-
createdAt: Date.now(),
|
|
47
|
-
};
|
|
48
|
-
list.push(entry);
|
|
49
|
-
await saveAll(list);
|
|
50
|
-
return entry;
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function update(id, patch) {
|
|
55
|
-
return withFileLock(FILE, async () => {
|
|
56
|
-
const list = await loadAll();
|
|
57
|
-
const idx = list.findIndex((f) => f.id === id);
|
|
58
|
-
if (idx < 0) return null;
|
|
59
|
-
// Allow rename + reorder, ignore other keys.
|
|
60
|
-
const allowed = {};
|
|
61
|
-
if (typeof patch.name === 'string') allowed.name = patch.name.trim();
|
|
62
|
-
if (typeof patch.order === 'number') allowed.order = patch.order;
|
|
63
|
-
list[idx] = { ...list[idx], ...allowed };
|
|
64
|
-
await saveAll(list);
|
|
65
|
-
return list[idx];
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function remove(id) {
|
|
70
|
-
return withFileLock(FILE, async () => {
|
|
71
|
-
const list = await loadAll();
|
|
72
|
-
const idx = list.findIndex((f) => f.id === id);
|
|
73
|
-
if (idx < 0) return false;
|
|
74
|
-
list.splice(idx, 1);
|
|
75
|
-
await saveAll(list);
|
|
76
|
-
return true;
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function reorder(idsInOrder) {
|
|
81
|
-
if (!Array.isArray(idsInOrder)) throw new Error('idsInOrder must be array');
|
|
82
|
-
return withFileLock(FILE, async () => {
|
|
83
|
-
const list = await loadAll();
|
|
84
|
-
const byId = new Map(list.map((f) => [f.id, f]));
|
|
85
|
-
const next = [];
|
|
86
|
-
idsInOrder.forEach((id, i) => {
|
|
87
|
-
const f = byId.get(id);
|
|
88
|
-
if (f) {
|
|
89
|
-
f.order = i;
|
|
90
|
-
next.push(f);
|
|
91
|
-
byId.delete(id);
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
// Append any folders not mentioned in the new order, preserving original
|
|
95
|
-
// relative order. Prevents accidentally dropping folders.
|
|
96
|
-
for (const f of byId.values()) {
|
|
97
|
-
f.order = next.length;
|
|
98
|
-
next.push(f);
|
|
99
|
-
}
|
|
100
|
-
await saveAll(next);
|
|
101
|
-
return next;
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
module.exports = { loadAll, create, update, remove, reorder, FILE };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// User-curated folders. Sessions reference these by id. Order is
|
|
4
|
+
// user-controlled (drag-reorder in sidebar). The store is a flat list
|
|
5
|
+
// in $DATA_DIR/folders.json:
|
|
6
|
+
// [{ id, name, order, createdAt }]
|
|
7
|
+
//
|
|
8
|
+
// Top-level "Unsorted" is implicit — sessions with folderId === null
|
|
9
|
+
// render under it. The user can't delete or rename it; we just synthesise
|
|
10
|
+
// the bucket in the frontend.
|
|
11
|
+
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const fs = require('node:fs/promises');
|
|
14
|
+
const { DATA_DIR } = require('./config');
|
|
15
|
+
const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
16
|
+
|
|
17
|
+
const FILE = path.join(DATA_DIR, 'folders.json');
|
|
18
|
+
|
|
19
|
+
async function loadAll() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await fs.readFile(FILE, 'utf8');
|
|
22
|
+
const j = JSON.parse(raw);
|
|
23
|
+
return Array.isArray(j) ? j : [];
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (e.code === 'ENOENT') return [];
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function saveAll(list) {
|
|
31
|
+
await atomicWriteJson(FILE, list);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function genId() {
|
|
35
|
+
return 'folder-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function create({ name }) {
|
|
39
|
+
if (!name || typeof name !== 'string') throw new Error('name required');
|
|
40
|
+
return withFileLock(FILE, async () => {
|
|
41
|
+
const list = await loadAll();
|
|
42
|
+
const entry = {
|
|
43
|
+
id: genId(),
|
|
44
|
+
name: name.trim(),
|
|
45
|
+
order: list.length,
|
|
46
|
+
createdAt: Date.now(),
|
|
47
|
+
};
|
|
48
|
+
list.push(entry);
|
|
49
|
+
await saveAll(list);
|
|
50
|
+
return entry;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function update(id, patch) {
|
|
55
|
+
return withFileLock(FILE, async () => {
|
|
56
|
+
const list = await loadAll();
|
|
57
|
+
const idx = list.findIndex((f) => f.id === id);
|
|
58
|
+
if (idx < 0) return null;
|
|
59
|
+
// Allow rename + reorder, ignore other keys.
|
|
60
|
+
const allowed = {};
|
|
61
|
+
if (typeof patch.name === 'string') allowed.name = patch.name.trim();
|
|
62
|
+
if (typeof patch.order === 'number') allowed.order = patch.order;
|
|
63
|
+
list[idx] = { ...list[idx], ...allowed };
|
|
64
|
+
await saveAll(list);
|
|
65
|
+
return list[idx];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function remove(id) {
|
|
70
|
+
return withFileLock(FILE, async () => {
|
|
71
|
+
const list = await loadAll();
|
|
72
|
+
const idx = list.findIndex((f) => f.id === id);
|
|
73
|
+
if (idx < 0) return false;
|
|
74
|
+
list.splice(idx, 1);
|
|
75
|
+
await saveAll(list);
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function reorder(idsInOrder) {
|
|
81
|
+
if (!Array.isArray(idsInOrder)) throw new Error('idsInOrder must be array');
|
|
82
|
+
return withFileLock(FILE, async () => {
|
|
83
|
+
const list = await loadAll();
|
|
84
|
+
const byId = new Map(list.map((f) => [f.id, f]));
|
|
85
|
+
const next = [];
|
|
86
|
+
idsInOrder.forEach((id, i) => {
|
|
87
|
+
const f = byId.get(id);
|
|
88
|
+
if (f) {
|
|
89
|
+
f.order = i;
|
|
90
|
+
next.push(f);
|
|
91
|
+
byId.delete(id);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
// Append any folders not mentioned in the new order, preserving original
|
|
95
|
+
// relative order. Prevents accidentally dropping folders.
|
|
96
|
+
for (const f of byId.values()) {
|
|
97
|
+
f.order = next.length;
|
|
98
|
+
next.push(f);
|
|
99
|
+
}
|
|
100
|
+
await saveAll(next);
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { loadAll, create, update, remove, reorder, FILE };
|