@oddessentials/agent-guild 0.28.1 → 0.30.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/README.md +4 -2
- package/package.json +1 -1
- package/src/manager/config.mjs +2 -0
- package/src/manager/folder-browser.mjs +39 -1
- package/src/manager/main.mjs +2 -0
- package/src/manager/notes.mjs +120 -0
- package/src/manager/server.mjs +25 -4
- package/web/app.js +357 -65
- package/web/index.html +9 -2
- package/web/skins/gnomeland/skin.css +2 -2
- package/web/skins/goblinville/skin.css +2 -2
- package/web/skins/grove/skin.css +2 -2
- package/web/skins/guild/skin.css +1 -1
- package/web/skins/orbital/skin.css +2 -2
- package/web/styles.css +11 -2
package/README.md
CHANGED
|
@@ -160,8 +160,10 @@ vX.Y.Z** switches over when you are ready. The version badge opens
|
|
|
160
160
|
|
|
161
161
|
**Keep notes at hand.** **Notes** in the top bar opens a notepad for prompts,
|
|
162
162
|
commands and to-dos in the side panel, next to your terminals. It saves up to
|
|
163
|
-
100,000 characters as you type
|
|
164
|
-
manager
|
|
163
|
+
100,000 characters as you type. The same notes follow every browser signed in
|
|
164
|
+
to this manager, including one opened through remote access. This browser
|
|
165
|
+
keeps a copy too: if the manager is unreachable, the line under the title
|
|
166
|
+
says the notes are saved in this browser only, and they sync when it is back.
|
|
165
167
|
|
|
166
168
|
**GitHub beside your work.** **GitHub** in the top bar opens the side panel.
|
|
167
169
|
Sign in to one or more GitHub accounts, then search the repositories of all
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/manager/config.mjs
CHANGED
|
@@ -45,6 +45,8 @@ export const paths = {
|
|
|
45
45
|
get accounts() { return path.join(dataDir(), 'accounts'); },
|
|
46
46
|
get github() { return path.join(dataDir(), 'github'); },
|
|
47
47
|
get remoteAccess() { return path.join(dataDir(), 'remote-access.json'); },
|
|
48
|
+
/** The shared notepad. A missing file means it was never stored. */
|
|
49
|
+
get notes() { return path.join(dataDir(), 'notes.json'); },
|
|
48
50
|
get log() { return path.join(dataDir(), 'manager.log'); },
|
|
49
51
|
/** Launchers for agent-guild-report, put first on every session's PATH. */
|
|
50
52
|
get shims() { return path.join(dataDir(), 'bin'); },
|
|
@@ -16,6 +16,20 @@ export function folderSegments(dir, paths = path) {
|
|
|
16
16
|
return segments;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(\..*)?$/i;
|
|
20
|
+
|
|
21
|
+
export function folderNameProblem(name, platform = process.platform) {
|
|
22
|
+
if (!name) return 'Enter a folder name.';
|
|
23
|
+
if (name === '.' || name === '..') return 'Choose a different folder name.';
|
|
24
|
+
if (/[/\\]/.test(name)) return 'A folder name cannot contain / or \\.';
|
|
25
|
+
if (/[\u0000-\u001f]/.test(name)) return 'A folder name cannot contain control characters.';
|
|
26
|
+
if (platform !== 'win32') return null;
|
|
27
|
+
if (/[<>:"|?*]/.test(name)) return 'A folder name cannot contain < > : " | ? or *.';
|
|
28
|
+
if (/[. ]$/.test(name)) return 'A folder name cannot end with a dot or a space.';
|
|
29
|
+
if (WINDOWS_RESERVED.test(name)) return `Windows reserves the name ${name}.`;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export function createFolderBrowser({ platform = process.platform, home = os.homedir(), limit = 2000, driveTimeoutMs = 500, fsp = fs.promises } = {}) {
|
|
20
34
|
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
21
35
|
|
|
@@ -59,7 +73,30 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom
|
|
|
59
73
|
return folders.filter(Boolean).sort(byName);
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
|
|
76
|
+
const browser = {
|
|
77
|
+
async create(rawParent, rawName) {
|
|
78
|
+
if (typeof rawParent !== 'string' || !rawParent.trim() || rawParent.includes('\0')) throw failure(400, 'bad_path', 'The folder must be a path.');
|
|
79
|
+
const name = typeof rawName === 'string' ? rawName.trim() : '';
|
|
80
|
+
const problem = folderNameProblem(name, platform);
|
|
81
|
+
if (problem) throw failure(400, 'bad_name', problem);
|
|
82
|
+
const parent = paths.resolve(rawParent.trim());
|
|
83
|
+
const missing = () => failure(409, 'folder_missing', `${parent} no longer exists.`);
|
|
84
|
+
try {
|
|
85
|
+
if (!(await fsp.stat(parent)).isDirectory()) throw missing();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.status) throw error;
|
|
88
|
+
throw error.code === 'ENOENT' || error.code === 'ENOTDIR' ? missing() : failure(409, 'folder_unreadable', `Agent Guild cannot read ${parent}.`);
|
|
89
|
+
}
|
|
90
|
+
const dir = paths.join(parent, name);
|
|
91
|
+
try {
|
|
92
|
+
await fsp.mkdir(dir);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error.code === 'EEXIST') throw failure(409, 'folder_exists', `${name} already exists in ${parent}.`);
|
|
95
|
+
if (error.code === 'ENOENT') throw missing();
|
|
96
|
+
throw failure(409, 'folder_unwritable', `Agent Guild cannot create ${dir}.`);
|
|
97
|
+
}
|
|
98
|
+
return browser.list(dir);
|
|
99
|
+
},
|
|
63
100
|
async list(raw) {
|
|
64
101
|
if (raw !== undefined && raw !== null && (typeof raw !== 'string' || raw.includes('\0'))) {
|
|
65
102
|
throw failure(400, 'bad_path', 'The folder must be a path.');
|
|
@@ -82,4 +119,5 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom
|
|
|
82
119
|
};
|
|
83
120
|
},
|
|
84
121
|
};
|
|
122
|
+
return browser;
|
|
85
123
|
}
|
package/src/manager/main.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { NewsFeed } from './news.mjs';
|
|
|
13
13
|
import { Changelog } from './changelog.mjs';
|
|
14
14
|
import { GitHub } from './github.mjs';
|
|
15
15
|
import { createManagerServer } from './server.mjs';
|
|
16
|
+
import { createNotesStore } from './notes.mjs';
|
|
16
17
|
import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
|
|
17
18
|
import { SelfUpdate } from './self-update.mjs';
|
|
18
19
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
@@ -138,6 +139,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
138
139
|
extraHosts: remoteSettings.config.access.hosts,
|
|
139
140
|
extraOrigins: remoteSettings.config.access.origins,
|
|
140
141
|
remoteAccess,
|
|
142
|
+
notes: createNotesStore(paths.notes),
|
|
141
143
|
launcher: launcherPath(),
|
|
142
144
|
onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
|
|
143
145
|
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// One notepad for every browser signed in to this manager.
|
|
2
|
+
// A missing file means notes were never stored. An empty string is a notepad
|
|
3
|
+
// the user cleared; those two must stay distinct so an old browser cannot
|
|
4
|
+
// put cleared notes back.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
export const NOTES_LIMIT = 100000;
|
|
11
|
+
/** Larger than the API's usual 64 KiB body, and enough for a full notepad after JSON escaping. */
|
|
12
|
+
export const NOTES_BODY_LIMIT = 1024 * 1024;
|
|
13
|
+
const NOTES_FILE_LIMIT = 2 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
function notesError(code, message, status, notes) {
|
|
16
|
+
// The store reports an unreadable file once, so the server does not log every request that hits it.
|
|
17
|
+
const error = Object.assign(new Error(message), { code, status, logged: code === 'notes_unreadable' });
|
|
18
|
+
if (notes) error.notes = notes;
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @returns {{ revision: string, text: string } | null} null when the file is not there yet */
|
|
23
|
+
function readNotes(file) {
|
|
24
|
+
let stat;
|
|
25
|
+
try { stat = fs.statSync(file); } catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT') return null;
|
|
27
|
+
throw notesError('notes_unreadable', 'Notes could not be read.', 500);
|
|
28
|
+
}
|
|
29
|
+
if (!stat.isFile() || stat.size > NOTES_FILE_LIMIT) throw notesError('notes_unreadable', 'Notes could not be read.', 500);
|
|
30
|
+
let value;
|
|
31
|
+
try { value = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
|
|
32
|
+
throw notesError('notes_unreadable', 'Notes could not be read.', 500);
|
|
33
|
+
}
|
|
34
|
+
if (!value || value.version !== 1 || typeof value.revision !== 'string' || !value.revision || value.revision.length > 100
|
|
35
|
+
|| typeof value.text !== 'string' || value.text.length > NOTES_LIMIT) {
|
|
36
|
+
throw notesError('notes_unreadable', 'Notes could not be read.', 500);
|
|
37
|
+
}
|
|
38
|
+
return { revision: value.revision, text: value.text };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeNotes(file, notes) {
|
|
42
|
+
const contents = `${JSON.stringify({ version: 1, revision: notes.revision, text: notes.text }, null, 2)}\n`;
|
|
43
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
44
|
+
const next = `${file}.${randomUUID()}.tmp`;
|
|
45
|
+
let descriptor;
|
|
46
|
+
try {
|
|
47
|
+
descriptor = fs.openSync(next, 'wx', 0o600);
|
|
48
|
+
fs.writeFileSync(descriptor, contents);
|
|
49
|
+
fs.fsyncSync(descriptor);
|
|
50
|
+
fs.closeSync(descriptor);
|
|
51
|
+
descriptor = undefined;
|
|
52
|
+
fs.renameSync(next, file);
|
|
53
|
+
} finally {
|
|
54
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
55
|
+
try { fs.unlinkSync(next); } catch { /* renamed, or never created */ }
|
|
56
|
+
}
|
|
57
|
+
try { fs.chmodSync(file, 0o600); } catch { /* not supported on Windows */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} [file] omit for an in-memory store (tests). The real manager passes its notes file.
|
|
62
|
+
* @param {{ warn?: (message: string) => void }} [options] where the one line about an unreadable file goes
|
|
63
|
+
*/
|
|
64
|
+
export function createNotesStore(file, { warn = (message) => console.warn(message) } = {}) {
|
|
65
|
+
let current = { revision: null, text: '' };
|
|
66
|
+
let loaded = !file;
|
|
67
|
+
let warned = false;
|
|
68
|
+
|
|
69
|
+
/** An unreadable file is read again next time, so fixing or removing it needs no restart. */
|
|
70
|
+
function ensure() {
|
|
71
|
+
if (loaded) return;
|
|
72
|
+
let read;
|
|
73
|
+
try { read = readNotes(file); } catch (error) {
|
|
74
|
+
if (!warned) warn(`[notes] ${file} cannot be read, so notes are not shared until it is fixed or removed.`);
|
|
75
|
+
warned = true;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
warned = false;
|
|
79
|
+
current = read ?? { revision: null, text: '' };
|
|
80
|
+
loaded = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function snapshot() {
|
|
84
|
+
ensure();
|
|
85
|
+
return { revision: current.revision, text: current.text };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function save(body) {
|
|
89
|
+
ensure();
|
|
90
|
+
if (!body || typeof body.text !== 'string') throw notesError('bad_notes', 'Notes must be text.', 400);
|
|
91
|
+
if (body.text.length > NOTES_LIMIT) {
|
|
92
|
+
throw notesError('notes_too_long', `Notes hold up to ${NOTES_LIMIT.toLocaleString('en-US')} characters.`, 400);
|
|
93
|
+
}
|
|
94
|
+
const revision = body.revision ?? null;
|
|
95
|
+
if (revision !== null && (typeof revision !== 'string' || !revision || revision.length > 100)) {
|
|
96
|
+
throw notesError('bad_notes', 'Notes must be text.', 400);
|
|
97
|
+
}
|
|
98
|
+
if (revision !== current.revision) {
|
|
99
|
+
throw notesError('stale_notes', 'Notes changed in another browser.', 409, { revision: current.revision, text: current.text });
|
|
100
|
+
}
|
|
101
|
+
const next = { revision: randomUUID(), text: body.text };
|
|
102
|
+
if (file) writeNotes(file, next);
|
|
103
|
+
current = next;
|
|
104
|
+
loaded = true;
|
|
105
|
+
return { revision: next.revision, text: next.text };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
snapshot,
|
|
110
|
+
save,
|
|
111
|
+
/** `{ known: false }` when the file cannot be read, so a client does not treat that as an empty notepad. */
|
|
112
|
+
helloRevision() {
|
|
113
|
+
try {
|
|
114
|
+
return { known: true, revision: snapshot().revision };
|
|
115
|
+
} catch {
|
|
116
|
+
return { known: false };
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { createViews } from './github-views.mjs';
|
|
|
12
12
|
import { createFolderOpener } from './folder-opener.mjs';
|
|
13
13
|
import { createFolderBrowser } from './folder-browser.mjs';
|
|
14
14
|
import { normalizeAccess } from './access-policy.mjs';
|
|
15
|
+
import { NOTES_BODY_LIMIT, createNotesStore } from './notes.mjs';
|
|
15
16
|
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
const API = '/api/v1';
|
|
@@ -63,7 +64,7 @@ class HttpError extends Error {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
function readJsonBody(req) {
|
|
67
|
+
function readJsonBody(req, max = MAX_BODY) {
|
|
67
68
|
return new Promise((resolve, reject) => {
|
|
68
69
|
let size = 0;
|
|
69
70
|
const chunks = [];
|
|
@@ -71,7 +72,7 @@ function readJsonBody(req) {
|
|
|
71
72
|
req.on('data', (chunk) => {
|
|
72
73
|
if (tooLarge) return; // drain the rest so the 413 response can be read
|
|
73
74
|
size += chunk.length;
|
|
74
|
-
if (size >
|
|
75
|
+
if (size > max) {
|
|
75
76
|
tooLarge = true;
|
|
76
77
|
chunks.length = 0;
|
|
77
78
|
reject(new HttpError(413, 'request body too large', 'too_large'));
|
|
@@ -113,6 +114,8 @@ export function createManagerServer({
|
|
|
113
114
|
extraHosts = [],
|
|
114
115
|
extraOrigins = [],
|
|
115
116
|
remoteAccess = null,
|
|
117
|
+
/** In-memory when omitted, so a test server never reads the user's notes file. */
|
|
118
|
+
notes = createNotesStore(),
|
|
116
119
|
folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
|
|
117
120
|
folderBrowser = createFolderBrowser(),
|
|
118
121
|
/** The double-click launcher file for this platform, or null when the package carries none. */
|
|
@@ -252,6 +255,15 @@ export function createManagerServer({
|
|
|
252
255
|
|
|
253
256
|
requireAuth(req, url);
|
|
254
257
|
|
|
258
|
+
if (route === '/notes' && method === 'GET') {
|
|
259
|
+
return sendJson(res, 200, { notes: notes.snapshot() });
|
|
260
|
+
}
|
|
261
|
+
if (route === '/notes' && method === 'PUT') {
|
|
262
|
+
const saved = notes.save(await readJsonBody(req, NOTES_BODY_LIMIT));
|
|
263
|
+
broadcast({ type: 'notes.updated', notes: saved });
|
|
264
|
+
return sendJson(res, 200, { notes: saved });
|
|
265
|
+
}
|
|
266
|
+
|
|
255
267
|
if (remoteAccess && route === '/remote-access' && method === 'GET') {
|
|
256
268
|
return sendJson(res, 200, { remoteAccess: remoteAccess.snapshot() });
|
|
257
269
|
}
|
|
@@ -287,6 +299,10 @@ export function createManagerServer({
|
|
|
287
299
|
if (route === '/folders' && method === 'GET') {
|
|
288
300
|
return sendJson(res, 200, await folderBrowser.list(url.searchParams.get('path') ?? undefined));
|
|
289
301
|
}
|
|
302
|
+
if (route === '/folders' && method === 'POST') {
|
|
303
|
+
const { path: parent, name } = await readJsonBody(req);
|
|
304
|
+
return sendJson(res, 201, await folderBrowser.create(parent, name));
|
|
305
|
+
}
|
|
290
306
|
if (route === '/upgrade' && method === 'POST') {
|
|
291
307
|
const session = await manager.upgrade();
|
|
292
308
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
@@ -482,12 +498,13 @@ export function createManagerServer({
|
|
|
482
498
|
else serveStatic(req, res, url.pathname);
|
|
483
499
|
} catch (err) {
|
|
484
500
|
const status = err.status || 500;
|
|
485
|
-
if (status >= 500) console.error('[server]', err);
|
|
501
|
+
if (status >= 500 && !err.logged) console.error('[server]', err);
|
|
486
502
|
if (!res.headersSent) {
|
|
487
503
|
const error = { code: err.code || 'error', message: err.message };
|
|
488
504
|
if (err.running !== undefined) error.running = err.running;
|
|
489
505
|
if (err.pending !== undefined) error.pending = err.pending;
|
|
490
506
|
if (err.target !== undefined) error.target = err.target;
|
|
507
|
+
if (err.notes !== undefined) error.notes = err.notes;
|
|
491
508
|
sendJson(res, status, { error });
|
|
492
509
|
}
|
|
493
510
|
}
|
|
@@ -535,7 +552,11 @@ export function createManagerServer({
|
|
|
535
552
|
|
|
536
553
|
function handleEvents(ws, req) {
|
|
537
554
|
eventClients.add(ws);
|
|
538
|
-
|
|
555
|
+
const hello = { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), sessions: manager.list() };
|
|
556
|
+
const notesHello = notes.helloRevision();
|
|
557
|
+
if (notesHello.known) hello.notesRevision = notesHello.revision;
|
|
558
|
+
else hello.notesUnreadable = true;
|
|
559
|
+
safeSend(ws, hello);
|
|
539
560
|
ws.on('close', () => eventClients.delete(ws));
|
|
540
561
|
ws.on('message', () => { /* events socket is server -> client only */ });
|
|
541
562
|
}
|
package/web/app.js
CHANGED
|
@@ -23,6 +23,7 @@ const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
|
|
|
23
23
|
const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
|
|
24
24
|
const HIDDEN_FOLDERS_KEY = 'agentGuild.showHiddenFolders';
|
|
25
25
|
const RECENT_CWDS_KEY = 'agentGuild.recentCwds';
|
|
26
|
+
const RECENT_CLONE_PARENTS_KEY = 'agentGuild.recentCloneParents';
|
|
26
27
|
const GITHUB_REPO_KEY = 'agentGuild.githubRepo';
|
|
27
28
|
const GITHUB_RECENT_KEY = 'agentGuild.githubRecent';
|
|
28
29
|
const GITHUB_VIEW_KEY = 'agentGuild.githubView';
|
|
@@ -30,6 +31,7 @@ const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
|
|
|
30
31
|
const SOUND_KEY = 'agentGuild.sound';
|
|
31
32
|
const VOICE_KEY = 'agentGuild.voice';
|
|
32
33
|
const NOTES_KEY = 'agentGuild.notes';
|
|
34
|
+
const NOTES_REV_KEY = 'agentGuild.notesRevision';
|
|
33
35
|
const DOCK_KEY = 'agentGuild.dock';
|
|
34
36
|
const DOCK_WIDTH_KEY = 'agentGuild.dockWidth';
|
|
35
37
|
const PANES_KEY = 'agentGuild.panes';
|
|
@@ -365,35 +367,63 @@ function moveInMenu(e) {
|
|
|
365
367
|
/** Notes share the page's storage with every other setting, so they stay far below the browser's limit for it. */
|
|
366
368
|
const NOTES_LIMIT = 100000;
|
|
367
369
|
const NOTES_STATUS = {
|
|
368
|
-
saved: 'Saved
|
|
370
|
+
saved: 'Saved and synced across your browsers',
|
|
371
|
+
local: 'Saved in this browser only. Syncs when the manager is back.',
|
|
372
|
+
damaged: 'Not syncing: the manager’s notes file is damaged. Saved in this browser only.',
|
|
369
373
|
long: `Not saved: notes hold up to ${NOTES_LIMIT.toLocaleString('en-US')} characters. Shorten them to save.`,
|
|
370
374
|
refused: 'Not saved: the browser’s storage for this page is full or turned off. Copy what you need before you close the page.',
|
|
371
375
|
};
|
|
376
|
+
/** How long a burst of typing waits before one save to the manager. */
|
|
377
|
+
const NOTES_PUSH_MS = 400;
|
|
378
|
+
/** Browsers drop a keepalive request larger than 64 KiB, so a bigger notepad waits for the next open. */
|
|
379
|
+
const NOTES_KEEPALIVE_BYTES = 60 * 1024;
|
|
372
380
|
/**
|
|
373
381
|
* `saved`: the notes as this page last read or wrote them in storage. `status`: whether the text in the
|
|
374
|
-
* panel is saved, too `long` or `refused`; `
|
|
382
|
+
* panel is saved, too `long` or `refused`; `sync`: whether the manager has it (`ok`), could not be reached
|
|
383
|
+
* (`local`) or cannot read its notes file (`damaged`); `shown`: the status the line under the title shows.
|
|
384
|
+
* `base`: the manager revision this page last saved from. `acked`: the text of that revision.
|
|
385
|
+
* `flight`: the save to the manager under way. Text typed meanwhile is sent when it finishes.
|
|
375
386
|
*/
|
|
376
|
-
const notesView = { saved: '', status: 'saved', shown: 'saved' };
|
|
387
|
+
const notesView = { saved: '', status: 'saved', sync: 'ok', shown: 'saved', base: null, acked: null, timer: 0, flight: null };
|
|
377
388
|
|
|
378
389
|
/** Shows notes another tab saved since this page last read or wrote them. Saved notes win over text this page could not save. */
|
|
379
390
|
function refreshNotes() {
|
|
380
391
|
let text;
|
|
381
392
|
// Unlike load(), a read that fails is not taken for empty notes.
|
|
382
393
|
try { text = localStorage.getItem(NOTES_KEY) ?? ''; } catch { return; }
|
|
383
|
-
|
|
384
|
-
notesView.saved
|
|
385
|
-
notesView.
|
|
386
|
-
|
|
394
|
+
const revision = load(NOTES_REV_KEY);
|
|
395
|
+
if (text === notesView.saved && revision === notesView.base) return;
|
|
396
|
+
if (text !== notesView.saved) {
|
|
397
|
+
notesView.saved = text;
|
|
398
|
+
notesView.status = 'saved';
|
|
399
|
+
$('notes-text').value = text;
|
|
400
|
+
}
|
|
401
|
+
// The revision is kept only while the stored text is the one it names, so that text is on the manager.
|
|
402
|
+
if (revision) notesView.acked = text;
|
|
403
|
+
if (revision !== notesView.base) notesView.base = revision;
|
|
387
404
|
renderNotesStatus();
|
|
388
405
|
guardLeaving();
|
|
389
406
|
}
|
|
390
407
|
|
|
391
|
-
/** Saves the notes on every change; emptying them removes the saved copy. */
|
|
408
|
+
/** Saves the notes on every change; emptying them removes the saved copy. This stays in the browser. */
|
|
392
409
|
function saveNotes() {
|
|
393
410
|
const text = $('notes-text').value;
|
|
394
|
-
if (text.length > NOTES_LIMIT)
|
|
395
|
-
|
|
396
|
-
|
|
411
|
+
if (text.length > NOTES_LIMIT) {
|
|
412
|
+
notesView.status = 'long';
|
|
413
|
+
renderNotesStatus();
|
|
414
|
+
guardLeaving();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// A reload can tell these keystrokes have not reached the manager: the stored revision goes first, so
|
|
418
|
+
// stored text never sits beside a revision it does not match. The revision in memory stays, for the save.
|
|
419
|
+
// An empty panel is probed with the same text, and that must not look like a new edit.
|
|
420
|
+
const revision = text !== notesView.saved ? load(NOTES_REV_KEY) : null;
|
|
421
|
+
if (revision) save(NOTES_REV_KEY, null);
|
|
422
|
+
if (!save(NOTES_KEY, text || null)) {
|
|
423
|
+
// The stored text did not change, so it still matches its revision.
|
|
424
|
+
if (revision) save(NOTES_REV_KEY, revision);
|
|
425
|
+
notesView.status = 'refused';
|
|
426
|
+
} else {
|
|
397
427
|
notesView.status = 'saved';
|
|
398
428
|
notesView.saved = text;
|
|
399
429
|
}
|
|
@@ -403,16 +433,30 @@ function saveNotes() {
|
|
|
403
433
|
|
|
404
434
|
/** The line under the title is a live region, so it is rewritten only when the status changes. */
|
|
405
435
|
function renderNotesStatus() {
|
|
406
|
-
|
|
407
|
-
|
|
436
|
+
// A problem saving in this browser outranks one reaching the manager.
|
|
437
|
+
const shown = notesView.status === 'saved' && notesView.sync !== 'ok' ? notesView.sync : notesView.status;
|
|
438
|
+
if (notesView.shown === shown) return;
|
|
439
|
+
notesView.shown = shown;
|
|
408
440
|
const sub = $('notes-sub');
|
|
409
|
-
sub.textContent = NOTES_STATUS[
|
|
410
|
-
sub.classList.toggle('warn',
|
|
441
|
+
sub.textContent = NOTES_STATUS[shown];
|
|
442
|
+
sub.classList.toggle('warn', shown !== 'saved');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Whether the manager has the notes. Typing never changes this, so the line does not flicker. */
|
|
446
|
+
function setNotesSync(sync) {
|
|
447
|
+
if (notesView.sync === sync) return;
|
|
448
|
+
notesView.sync = sync;
|
|
449
|
+
renderNotesStatus();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** A save or read that failed. A file the manager cannot read is reported as such; anything else is unreachable. */
|
|
453
|
+
function notesFailed(error) {
|
|
454
|
+
setNotesSync(error?.code === 'notes_unreadable' ? 'damaged' : 'local');
|
|
411
455
|
}
|
|
412
456
|
|
|
413
457
|
/** Another tab saved the notes, or cleared this page's storage. */
|
|
414
458
|
function notesStored(e) {
|
|
415
|
-
if (e.key === NOTES_KEY || e.key === null) refreshNotes();
|
|
459
|
+
if (e.key === NOTES_KEY || e.key === NOTES_REV_KEY || e.key === null) refreshNotes();
|
|
416
460
|
}
|
|
417
461
|
|
|
418
462
|
function openNotes({ focus = true } = {}) {
|
|
@@ -429,6 +473,181 @@ function toggleNotes() {
|
|
|
429
473
|
else openNotes();
|
|
430
474
|
}
|
|
431
475
|
|
|
476
|
+
function sharedPrefix(left, right) {
|
|
477
|
+
const end = Math.min(left.length, right.length);
|
|
478
|
+
let i = 0;
|
|
479
|
+
while (i < end && left.charCodeAt(i) === right.charCodeAt(i)) i += 1;
|
|
480
|
+
return i;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Takes the manager's notes. A selection that still sits in the unchanged prefix stays put. */
|
|
484
|
+
function adoptNotes(notes) {
|
|
485
|
+
const area = $('notes-text');
|
|
486
|
+
const next = notes.text;
|
|
487
|
+
if (typeof next !== 'string') return;
|
|
488
|
+
if (next !== area.value) {
|
|
489
|
+
const start = area.selectionStart;
|
|
490
|
+
const end = area.selectionEnd;
|
|
491
|
+
const previous = area.value;
|
|
492
|
+
const focused = document.activeElement === area;
|
|
493
|
+
area.value = next;
|
|
494
|
+
if (focused) {
|
|
495
|
+
const keep = sharedPrefix(previous, next);
|
|
496
|
+
if (start <= keep && end <= keep) area.setSelectionRange(start, end);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
notesView.saved = next;
|
|
500
|
+
notesView.acked = next;
|
|
501
|
+
notesView.base = notes.revision ?? null;
|
|
502
|
+
notesView.status = 'saved';
|
|
503
|
+
notesView.sync = 'ok';
|
|
504
|
+
// Revision last, and only over text that was stored: a stored revision always names the stored text.
|
|
505
|
+
save(NOTES_REV_KEY, null);
|
|
506
|
+
if (save(NOTES_KEY, next || null)) save(NOTES_REV_KEY, notes.revision || null);
|
|
507
|
+
renderNotesStatus();
|
|
508
|
+
guardLeaving();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function notesPushable() {
|
|
512
|
+
const text = $('notes-text').value;
|
|
513
|
+
if (notesView.status !== 'saved' || text.length > NOTES_LIMIT) return false;
|
|
514
|
+
if (text === notesView.acked) return false;
|
|
515
|
+
if (notesView.base == null && text === '') return false;
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function scheduleNotesPush(delay = NOTES_PUSH_MS) {
|
|
520
|
+
clearTimeout(notesView.timer);
|
|
521
|
+
notesView.timer = 0;
|
|
522
|
+
if (!notesPushable()) return Promise.resolve();
|
|
523
|
+
if (delay === 0) return pushNotes();
|
|
524
|
+
notesView.timer = setTimeout(() => { notesView.timer = 0; void pushNotes(); }, delay);
|
|
525
|
+
return Promise.resolve();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The page is being hidden or closed: send what the debounce still holds. */
|
|
529
|
+
function flushNotes() {
|
|
530
|
+
void scheduleNotesPush(0);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* One save to the manager at a time, so two of this page's own saves can never race each other. Text typed
|
|
535
|
+
* while a save is under way follows it. A conflict sends this page's text once more at the manager's revision:
|
|
536
|
+
* the latest typing wins, and the text on screen is never replaced by an older copy.
|
|
537
|
+
*/
|
|
538
|
+
function pushNotes() {
|
|
539
|
+
// The save under way checks for newer text when it finishes.
|
|
540
|
+
if (notesView.flight) return notesView.flight;
|
|
541
|
+
const run = (async () => {
|
|
542
|
+
let resent = false;
|
|
543
|
+
for (;;) {
|
|
544
|
+
if (!notesPushable()) return;
|
|
545
|
+
const outcome = await sendNotes($('notes-text').value);
|
|
546
|
+
if (outcome === 'stale' && !resent) resent = true;
|
|
547
|
+
else if (outcome !== 'ok') return;
|
|
548
|
+
}
|
|
549
|
+
})();
|
|
550
|
+
notesView.flight = run;
|
|
551
|
+
return run.finally(() => { notesView.flight = null; });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** @returns {Promise<'ok'|'stale'|'failed'>} `stale`: the manager moved on, and `base` now names its revision. */
|
|
555
|
+
async function sendNotes(text) {
|
|
556
|
+
const body = { revision: notesView.base, text };
|
|
557
|
+
// A hidden page may be closing: keepalive lets the save outlive it, for bodies the browser allows.
|
|
558
|
+
const keepalive = document.visibilityState === 'hidden'
|
|
559
|
+
&& new TextEncoder().encode(JSON.stringify(body)).length <= NOTES_KEEPALIVE_BYTES;
|
|
560
|
+
let payload;
|
|
561
|
+
try {
|
|
562
|
+
payload = (await api('PUT', '/notes', body, { keepalive })).notes;
|
|
563
|
+
} catch (error) {
|
|
564
|
+
if (error instanceof AuthError) {
|
|
565
|
+
showAuth(error.message);
|
|
566
|
+
return 'failed';
|
|
567
|
+
}
|
|
568
|
+
if (error.code !== 'stale_notes' || !error.notes || typeof error.notes.text !== 'string') {
|
|
569
|
+
notesFailed(error);
|
|
570
|
+
return 'failed';
|
|
571
|
+
}
|
|
572
|
+
// The manager already holds exactly this text, saved by another tab or an earlier request.
|
|
573
|
+
if (error.notes.text === $('notes-text').value) {
|
|
574
|
+
adoptNotes(error.notes);
|
|
575
|
+
return 'ok';
|
|
576
|
+
}
|
|
577
|
+
notesView.base = error.notes.revision ?? null;
|
|
578
|
+
return 'stale';
|
|
579
|
+
}
|
|
580
|
+
if (!payload || typeof payload.text !== 'string') {
|
|
581
|
+
notesFailed();
|
|
582
|
+
return 'failed';
|
|
583
|
+
}
|
|
584
|
+
if ($('notes-text').value === text) adoptNotes(payload);
|
|
585
|
+
else {
|
|
586
|
+
// Typing went on during the save: the newer text follows from this revision.
|
|
587
|
+
notesView.base = payload.revision ?? notesView.base;
|
|
588
|
+
setNotesSync('ok');
|
|
589
|
+
}
|
|
590
|
+
return 'ok';
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Another browser's save. Our own echo matches the text already on screen and only adopts the revision. */
|
|
594
|
+
function applyServerNotes(notes) {
|
|
595
|
+
if (!notes || typeof notes.text !== 'string') return;
|
|
596
|
+
const area = $('notes-text');
|
|
597
|
+
if (notes.text === area.value) {
|
|
598
|
+
adoptNotes(notes);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (notesView.acked !== null && area.value !== notesView.acked) {
|
|
602
|
+
notesView.base = notes.revision ?? notesView.base;
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
adoptNotes(notes);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The notes part of hello. A missing revision means this manager cannot share notes. */
|
|
609
|
+
async function catchUpNotes(revision, unreadable) {
|
|
610
|
+
if (unreadable) {
|
|
611
|
+
setNotesSync('damaged');
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (revision === undefined) return;
|
|
615
|
+
// Hello can arrive before the first read. Load this browser's copy before deciding, unless typing has started.
|
|
616
|
+
if (notesView.saved === '' && $('notes-text').value === '' && notesView.acked === null) refreshNotes();
|
|
617
|
+
const local = $('notes-text').value;
|
|
618
|
+
// No file yet, or it was removed. Text this browser has is the first copy. An empty panel writes nothing.
|
|
619
|
+
if (revision === null) notesView.acked = local ? null : '';
|
|
620
|
+
if (revision === null || revision === notesView.base) {
|
|
621
|
+
if (notesPushable()) await scheduleNotesPush(0);
|
|
622
|
+
else setNotesSync('ok');
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
await pullNotes();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The manager moved on while this page was away. Its copy wins unless this browser has edits it never sent. */
|
|
629
|
+
async function pullNotes() {
|
|
630
|
+
let notes;
|
|
631
|
+
try {
|
|
632
|
+
notes = (await api('GET', '/notes')).notes;
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (error instanceof AuthError) showAuth(error.message);
|
|
635
|
+
else notesFailed(error);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (!notes || typeof notes.text !== 'string') return;
|
|
639
|
+
const local = $('notes-text').value;
|
|
640
|
+
// `acked` is unknown only when this browser holds text no revision names: an edit that never reached the manager.
|
|
641
|
+
const unsent = local !== notes.text && (notesView.acked === null ? local !== '' : local !== notesView.acked);
|
|
642
|
+
if (!unsent) {
|
|
643
|
+
adoptNotes(notes);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
notesView.base = notes.revision ?? null;
|
|
647
|
+
setNotesSync('ok');
|
|
648
|
+
await scheduleNotesPush(0);
|
|
649
|
+
}
|
|
650
|
+
|
|
432
651
|
// ---- dock -----------------------------------------------------------------
|
|
433
652
|
|
|
434
653
|
const DOCK_PANELS = ['github', 'notes'];
|
|
@@ -716,9 +935,10 @@ function renderAgents(container, agents, shells = []) {
|
|
|
716
935
|
|
|
717
936
|
class AuthError extends Error {}
|
|
718
937
|
|
|
719
|
-
async function api(method, path, body) {
|
|
938
|
+
async function api(method, path, body, options = {}) {
|
|
720
939
|
const res = await fetch(`/api/v1${path}`, {
|
|
721
940
|
method,
|
|
941
|
+
keepalive: options.keepalive === true,
|
|
722
942
|
headers: {
|
|
723
943
|
Authorization: `Bearer ${state.token}`,
|
|
724
944
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
@@ -767,68 +987,78 @@ async function openWorkingFolder() {
|
|
|
767
987
|
}
|
|
768
988
|
}
|
|
769
989
|
|
|
770
|
-
// ---- recent
|
|
990
|
+
// ---- recent folders ---------------------------------------------------------
|
|
771
991
|
|
|
772
|
-
const
|
|
992
|
+
const RECENT_FIELDS = {
|
|
993
|
+
cwd: { input: 'cwd', list: 'cwd-recent', key: RECENT_CWDS_KEY, pick: (dir) => useFolder(dir) },
|
|
994
|
+
clone: { input: 'github-parent', list: 'github-parent-recent', key: RECENT_CLONE_PARENTS_KEY, pick: (dir) => setCloneParent(dir) },
|
|
995
|
+
};
|
|
996
|
+
const recentView = { field: null, typed: false, active: -1 };
|
|
773
997
|
|
|
774
|
-
function
|
|
775
|
-
return readRecentFolders(load(
|
|
998
|
+
function recentFolders(field) {
|
|
999
|
+
return readRecentFolders(load(RECENT_FIELDS[field].key));
|
|
776
1000
|
}
|
|
777
1001
|
|
|
778
|
-
function
|
|
779
|
-
if (dir) save(
|
|
1002
|
+
function rememberRecent(field, dir) {
|
|
1003
|
+
if (dir) save(RECENT_FIELDS[field].key, JSON.stringify(rememberFolder(recentFolders(field), dir, { caseless: state.platform === 'win32' })));
|
|
780
1004
|
}
|
|
781
1005
|
|
|
782
|
-
function
|
|
783
|
-
const input
|
|
784
|
-
const
|
|
785
|
-
|
|
1006
|
+
function renderRecent(field) {
|
|
1007
|
+
const { input: inputId, list: listId } = RECENT_FIELDS[field];
|
|
1008
|
+
const input = $(inputId), list = $(listId);
|
|
1009
|
+
const open = recentView.field === field;
|
|
1010
|
+
const shown = open ? matchFolders(recentFolders(field), recentView.typed ? input.value : '') : [];
|
|
1011
|
+
if (open) recentView.active = Math.min(recentView.active, shown.length - 1);
|
|
786
1012
|
list.replaceChildren(...shown.map((dir, i) => {
|
|
787
1013
|
const option = el('li', 'cwd-recent-option', dir);
|
|
788
|
-
option.id =
|
|
1014
|
+
option.id = `${listId}-${i}`;
|
|
789
1015
|
option.setAttribute('role', 'option');
|
|
790
|
-
option.setAttribute('aria-selected', String(i === recentView.active));
|
|
1016
|
+
option.setAttribute('aria-selected', String(open && i === recentView.active));
|
|
791
1017
|
option.addEventListener('pointerdown', (e) => e.preventDefault());
|
|
792
|
-
option.addEventListener('click', () =>
|
|
1018
|
+
option.addEventListener('click', () => pickRecent(field, dir));
|
|
793
1019
|
return option;
|
|
794
1020
|
}));
|
|
795
1021
|
list.hidden = !shown.length;
|
|
796
1022
|
input.setAttribute('aria-expanded', String(!list.hidden));
|
|
797
|
-
if (recentView.active >= 0) input.setAttribute('aria-activedescendant',
|
|
1023
|
+
if (open && recentView.active >= 0) input.setAttribute('aria-activedescendant', `${listId}-${recentView.active}`);
|
|
798
1024
|
else input.removeAttribute('aria-activedescendant');
|
|
799
1025
|
}
|
|
800
1026
|
|
|
801
|
-
function
|
|
802
|
-
|
|
803
|
-
|
|
1027
|
+
function showRecent(field, typed = false) {
|
|
1028
|
+
const previous = recentView.field;
|
|
1029
|
+
Object.assign(recentView, { field, typed, active: -1 });
|
|
1030
|
+
if (previous && previous !== field) renderRecent(previous);
|
|
1031
|
+
renderRecent(field);
|
|
804
1032
|
}
|
|
805
1033
|
|
|
806
|
-
function
|
|
807
|
-
|
|
808
|
-
|
|
1034
|
+
function hideRecent(field) {
|
|
1035
|
+
if (recentView.field !== field) return;
|
|
1036
|
+
Object.assign(recentView, { field: null, active: -1 });
|
|
1037
|
+
renderRecent(field);
|
|
809
1038
|
}
|
|
810
1039
|
|
|
811
|
-
function
|
|
812
|
-
|
|
813
|
-
|
|
1040
|
+
function pickRecent(field, dir) {
|
|
1041
|
+
hideRecent(field);
|
|
1042
|
+
RECENT_FIELDS[field].pick(dir);
|
|
814
1043
|
}
|
|
815
1044
|
|
|
816
|
-
function
|
|
817
|
-
const
|
|
1045
|
+
function recentKeys(field, e) {
|
|
1046
|
+
const list = $(RECENT_FIELDS[field].list);
|
|
1047
|
+
const options = list.hidden ? [] : [...list.children];
|
|
818
1048
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
819
1049
|
e.preventDefault();
|
|
820
|
-
if (
|
|
821
|
-
const count =
|
|
1050
|
+
if (recentView.field !== field) showRecent(field);
|
|
1051
|
+
const count = list.children.length;
|
|
822
1052
|
if (!count) return;
|
|
823
1053
|
recentView.active = e.key === 'ArrowDown' ? (recentView.active + 1) % count : recentView.active <= 0 ? count - 1 : recentView.active - 1;
|
|
824
|
-
|
|
825
|
-
$(
|
|
1054
|
+
renderRecent(field);
|
|
1055
|
+
$(`${RECENT_FIELDS[field].list}-${recentView.active}`).scrollIntoView?.({ block: 'nearest' });
|
|
826
1056
|
} else if (e.key === 'Enter' && recentView.active >= 0 && options[recentView.active]) {
|
|
827
1057
|
e.preventDefault();
|
|
828
|
-
|
|
1058
|
+
pickRecent(field, options[recentView.active].textContent);
|
|
829
1059
|
} else if (e.key === 'Escape' && options.length) {
|
|
830
1060
|
e.preventDefault();
|
|
831
|
-
|
|
1061
|
+
hideRecent(field);
|
|
832
1062
|
}
|
|
833
1063
|
}
|
|
834
1064
|
|
|
@@ -854,7 +1084,52 @@ function chooseFolder(field) {
|
|
|
854
1084
|
browseTo($(FOLDER_FIELDS[field].input).value.trim());
|
|
855
1085
|
}
|
|
856
1086
|
|
|
1087
|
+
function showNewFolder(open) {
|
|
1088
|
+
$('folder-new').hidden = !open;
|
|
1089
|
+
$('folder-new-open').setAttribute('aria-expanded', String(open));
|
|
1090
|
+
if (open) $('folder-new-name').value = '';
|
|
1091
|
+
renderFolderBrowser();
|
|
1092
|
+
(open ? $('folder-new-name') : $('folder-new-open')).focus();
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function createFolder(e) {
|
|
1096
|
+
e?.preventDefault();
|
|
1097
|
+
const parent = folderView.listing?.path;
|
|
1098
|
+
const name = $('folder-new-name').value.trim();
|
|
1099
|
+
if (!parent || !name || folderView.loading) return;
|
|
1100
|
+
const request = ++folderView.request;
|
|
1101
|
+
folderView.loading = true;
|
|
1102
|
+
folderView.error = null;
|
|
1103
|
+
renderFolderBrowser();
|
|
1104
|
+
try {
|
|
1105
|
+
const listing = await api('POST', '/folders', { path: parent, name });
|
|
1106
|
+
if (request !== folderView.request) return;
|
|
1107
|
+
folderView.listing = listing;
|
|
1108
|
+
$('folder-filter').value = '';
|
|
1109
|
+
$('folder-new').hidden = true;
|
|
1110
|
+
$('folder-new-open').setAttribute('aria-expanded', 'false');
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
if (request !== folderView.request) return;
|
|
1113
|
+
if (err instanceof AuthError) {
|
|
1114
|
+
$('folder-browser').close();
|
|
1115
|
+
return showAuth(err.message);
|
|
1116
|
+
}
|
|
1117
|
+
folderView.error = err.message || 'Could not create the folder.';
|
|
1118
|
+
}
|
|
1119
|
+
folderView.loading = false;
|
|
1120
|
+
renderFolderBrowser();
|
|
1121
|
+
if ($('folder-browser').open) ($('folder-new').hidden ? $('folder-use') : $('folder-new-name')).focus();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function newFolderKeys(e) {
|
|
1125
|
+
if (e.key !== 'Escape') return;
|
|
1126
|
+
e.preventDefault();
|
|
1127
|
+
showNewFolder(false);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
857
1130
|
async function browseTo(dir) {
|
|
1131
|
+
$('folder-new').hidden = true;
|
|
1132
|
+
$('folder-new-open').setAttribute('aria-expanded', 'false');
|
|
858
1133
|
const request = ++folderView.request;
|
|
859
1134
|
folderView.loading = true;
|
|
860
1135
|
folderView.error = null;
|
|
@@ -908,6 +1183,8 @@ function renderFolderBrowser() {
|
|
|
908
1183
|
status.classList.toggle('error', Boolean(error));
|
|
909
1184
|
$('folder-current').textContent = listing?.path ?? '';
|
|
910
1185
|
$('folder-use').disabled = loading || !listing;
|
|
1186
|
+
$('folder-new-open').disabled = loading || !listing;
|
|
1187
|
+
$('folder-new-create').disabled = loading || !listing || !$('folder-new-name').value.trim();
|
|
911
1188
|
}
|
|
912
1189
|
|
|
913
1190
|
function folderBrowserClosed() {
|
|
@@ -925,11 +1202,9 @@ function useBrowsedFolder() {
|
|
|
925
1202
|
if (!dir || folderView.loading) return;
|
|
926
1203
|
const field = folderView.field;
|
|
927
1204
|
$('folder-browser').close();
|
|
1205
|
+
rememberRecent(field, dir);
|
|
928
1206
|
if (field === 'clone') setCloneParent(dir);
|
|
929
|
-
else
|
|
930
|
-
rememberCwd(dir);
|
|
931
|
-
useFolder(dir);
|
|
932
|
-
}
|
|
1207
|
+
else useFolder(dir);
|
|
933
1208
|
}
|
|
934
1209
|
|
|
935
1210
|
function moveInFolders(e) {
|
|
@@ -2418,7 +2693,7 @@ async function startSession(provider, card, { resume, cwd, account = selectedAcc
|
|
|
2418
2693
|
({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
|
|
2419
2694
|
}
|
|
2420
2695
|
upsertSession(session);
|
|
2421
|
-
|
|
2696
|
+
rememberRecent('cwd', session.cwd);
|
|
2422
2697
|
closeHistory({ focusOpener: false });
|
|
2423
2698
|
openPanel(session.id);
|
|
2424
2699
|
} catch (err) {
|
|
@@ -2628,7 +2903,7 @@ const GITHUB_SCOPES = {
|
|
|
2628
2903
|
repo: 'Read and write access to your repositories, private ones included, so you can browse repositories and create or edit issues.',
|
|
2629
2904
|
'write:public_key': 'Add the SSH key Agent Guild creates for this account.',
|
|
2630
2905
|
};
|
|
2631
|
-
const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, card: null, started: new
|
|
2906
|
+
const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, card: null, started: new Map(), announced: new Set() };
|
|
2632
2907
|
let githubLoading = null;
|
|
2633
2908
|
|
|
2634
2909
|
function el(tag, className, ...children) {
|
|
@@ -3093,8 +3368,9 @@ function renderGitHubRepos(github, account, repos) {
|
|
|
3093
3368
|
}
|
|
3094
3369
|
|
|
3095
3370
|
async function startClone(account, fullName) {
|
|
3096
|
-
const
|
|
3097
|
-
|
|
3371
|
+
const parent = cloneParent();
|
|
3372
|
+
const { session } = await api('POST', '/github/clone', { account: account.id, repo: fullName, parent });
|
|
3373
|
+
githubView.started.set(session.id, parent);
|
|
3098
3374
|
upsertSession(session);
|
|
3099
3375
|
openPanel(session.id);
|
|
3100
3376
|
}
|
|
@@ -3204,8 +3480,10 @@ function noticeClone(s) {
|
|
|
3204
3480
|
githubPick.origins.clear();
|
|
3205
3481
|
if (dockShows('github')) loadRepos();
|
|
3206
3482
|
if (!githubView.started.has(s.id)) return;
|
|
3207
|
-
if (clonedPath(s))
|
|
3208
|
-
|
|
3483
|
+
if (clonedPath(s)) {
|
|
3484
|
+
rememberRecent('clone', githubView.started.get(s.id));
|
|
3485
|
+
toast(`Cloned ${s.clone.repo} into ${s.clone.path}.`, 12000, { label: 'Use folder', run: () => useFolder(s.clone.path) });
|
|
3486
|
+
} else toast(`Cloning ${s.clone?.repo ?? 'the repository'} did not finish. Its session shows why.`, 8000);
|
|
3209
3487
|
}
|
|
3210
3488
|
|
|
3211
3489
|
function useFolder(dir) {
|
|
@@ -5110,6 +5388,9 @@ function connectEvents() {
|
|
|
5110
5388
|
// A changelog.updated sent while the socket was down is lost; catch up the open panel.
|
|
5111
5389
|
if ($('changelog').open) loadChangelog();
|
|
5112
5390
|
if (dockShows('github')) loadGitHub();
|
|
5391
|
+
catchUpNotes(msg.notesRevision, msg.notesUnreadable === true);
|
|
5392
|
+
} else if (msg.type === 'notes.updated') {
|
|
5393
|
+
applyServerNotes(msg.notes);
|
|
5113
5394
|
} else if (msg.type === 'remote-access.updated') {
|
|
5114
5395
|
state.remoteAccessUI?.updated();
|
|
5115
5396
|
} else if (msg.type === 'news.updated') {
|
|
@@ -5398,7 +5679,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
5398
5679
|
addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
|
|
5399
5680
|
addEventListener('resize', () => { if (tipFor) hideTip(); });
|
|
5400
5681
|
$('notes-open').addEventListener('click', firstClick(toggleNotes));
|
|
5401
|
-
$('notes-text').addEventListener('input', saveNotes);
|
|
5682
|
+
$('notes-text').addEventListener('input', () => { saveNotes(); void scheduleNotesPush(); });
|
|
5402
5683
|
addEventListener('storage', notesStored);
|
|
5403
5684
|
// On load, and again for a page back from the back/forward cache, which may have missed another tab's notes.
|
|
5404
5685
|
addEventListener('pageshow', refreshNotes);
|
|
@@ -5415,12 +5696,16 @@ $('news').addEventListener('close', () => {
|
|
|
5415
5696
|
newsView.opener = null;
|
|
5416
5697
|
});
|
|
5417
5698
|
document.addEventListener('visibilitychange', () => {
|
|
5418
|
-
if (document.visibilityState !== 'visible')
|
|
5699
|
+
if (document.visibilityState !== 'visible') {
|
|
5700
|
+
stopDictation();
|
|
5701
|
+
flushNotes();
|
|
5702
|
+
}
|
|
5419
5703
|
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
5420
5704
|
if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
|
|
5421
5705
|
else scheduleRuns();
|
|
5422
5706
|
});
|
|
5423
5707
|
addEventListener('pagehide', () => {
|
|
5708
|
+
flushNotes();
|
|
5424
5709
|
state.pageAway = true;
|
|
5425
5710
|
activityFavicon.setPaused(true);
|
|
5426
5711
|
terminalCopy.close();
|
|
@@ -5587,11 +5872,13 @@ $('panel-stop').addEventListener('click', () => {
|
|
|
5587
5872
|
});
|
|
5588
5873
|
$('cwd').value = load(CWD_KEY) || '';
|
|
5589
5874
|
$('cwd-pick').addEventListener('click', firstClick(() => chooseFolder('cwd')));
|
|
5590
|
-
|
|
5591
|
-
$(
|
|
5592
|
-
$(
|
|
5593
|
-
$(
|
|
5594
|
-
$(
|
|
5875
|
+
for (const [field, { input }] of Object.entries(RECENT_FIELDS)) {
|
|
5876
|
+
$(input).addEventListener('focus', () => showRecent(field));
|
|
5877
|
+
$(input).addEventListener('click', () => { if (recentView.field !== field) showRecent(field); });
|
|
5878
|
+
$(input).addEventListener('input', () => showRecent(field, true));
|
|
5879
|
+
$(input).addEventListener('blur', () => hideRecent(field));
|
|
5880
|
+
$(input).addEventListener('keydown', (e) => recentKeys(field, e));
|
|
5881
|
+
}
|
|
5595
5882
|
$('folder-cancel').addEventListener('click', () => $('folder-browser').close());
|
|
5596
5883
|
$('folder-browser').addEventListener('click', (e) => { if (e.target === $('folder-browser')) $('folder-browser').close(); });
|
|
5597
5884
|
$('folder-browser').addEventListener('close', folderBrowserClosed);
|
|
@@ -5605,6 +5892,11 @@ $('folder-hidden').addEventListener('change', (e) => {
|
|
|
5605
5892
|
renderFolderBrowser();
|
|
5606
5893
|
});
|
|
5607
5894
|
$('folder-use').addEventListener('click', useBrowsedFolder);
|
|
5895
|
+
$('folder-new-open').addEventListener('click', () => showNewFolder($('folder-new').hidden));
|
|
5896
|
+
$('folder-new-cancel').addEventListener('click', () => showNewFolder(false));
|
|
5897
|
+
$('folder-new').addEventListener('submit', createFolder);
|
|
5898
|
+
$('folder-new-name').addEventListener('input', renderFolderBrowser);
|
|
5899
|
+
$('folder-new-name').addEventListener('keydown', newFolderKeys);
|
|
5608
5900
|
$('cwd-open').addEventListener('click', firstClick(openWorkingFolder));
|
|
5609
5901
|
try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
|
|
5610
5902
|
try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
|
package/web/index.html
CHANGED
|
@@ -244,7 +244,8 @@
|
|
|
244
244
|
<div id="github-clone-into" class="history-form github-form">
|
|
245
245
|
<label for="github-parent">Clone into</label>
|
|
246
246
|
<div class="cwd-field single">
|
|
247
|
-
<input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off">
|
|
247
|
+
<input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="github-parent-recent">
|
|
248
|
+
<ul id="github-parent-recent" class="cwd-recent above" role="listbox" aria-label="Recent clone folders" hidden></ul>
|
|
248
249
|
<div class="cwd-tools">
|
|
249
250
|
<button id="github-parent-pick" class="cwd-tool" type="button" aria-label="Choose a folder…" title="Connect to the session manager to choose folders" disabled>
|
|
250
251
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
|
@@ -257,7 +258,7 @@
|
|
|
257
258
|
</div>
|
|
258
259
|
</section>
|
|
259
260
|
<section id="notes" class="dock-panel notes-panel" role="tabpanel" aria-labelledby="notes-title" hidden>
|
|
260
|
-
<p id="notes-sub" class="dock-sub sub" aria-live="polite">Saved
|
|
261
|
+
<p id="notes-sub" class="dock-sub sub" aria-live="polite">Saved and synced across your browsers</p>
|
|
261
262
|
<div class="dock-body">
|
|
262
263
|
<textarea id="notes-text" class="notes-text" aria-labelledby="notes-title" aria-describedby="notes-sub" autocomplete="off" placeholder="Prompts, commands, to-dos: anything you want to keep at hand"></textarea>
|
|
263
264
|
</div>
|
|
@@ -434,7 +435,13 @@
|
|
|
434
435
|
<button id="folder-up" class="btn" type="button">Up</button>
|
|
435
436
|
<button id="folder-home" class="btn folder-chip" type="button">Home</button>
|
|
436
437
|
<div id="folder-roots" class="folder-roots"></div>
|
|
438
|
+
<button id="folder-new-open" class="btn folder-new-open" type="button" aria-expanded="false" aria-controls="folder-new">New folder</button>
|
|
437
439
|
</div>
|
|
440
|
+
<form id="folder-new" class="history-tools folder-new" hidden>
|
|
441
|
+
<input id="folder-new-name" type="text" placeholder="New folder name" aria-label="New folder name" spellcheck="false" autocomplete="off" autocapitalize="off" enterkeyhint="done">
|
|
442
|
+
<button id="folder-new-create" class="btn primary" type="submit">Create</button>
|
|
443
|
+
<button id="folder-new-cancel" class="btn" type="button">Cancel</button>
|
|
444
|
+
</form>
|
|
438
445
|
<div class="history-tools">
|
|
439
446
|
<input id="folder-filter" type="search" placeholder="Filter folders" aria-label="Filter folders" spellcheck="false" autocomplete="off">
|
|
440
447
|
<label class="history-here"><input id="folder-hidden" type="checkbox"><span>Show hidden</span></label>
|
|
@@ -243,7 +243,7 @@
|
|
|
243
243
|
}
|
|
244
244
|
.btn.primary:hover { filter: brightness(1.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 0 0 1px rgba(233, 196, 122, 0.6), 0 5px 16px rgba(40, 70, 30, 0.35); }
|
|
245
245
|
.btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
|
|
246
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
|
|
246
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
|
|
247
247
|
|
|
248
248
|
.models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--panel-strong); backdrop-filter: blur(16px); }
|
|
249
249
|
.models, .news-panel { box-shadow: var(--dialog-shadow); }
|
|
@@ -362,7 +362,7 @@
|
|
|
362
362
|
.btn.primary { border-color: #6a8f3a; background: linear-gradient(#55924a, #35662f); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 0 0 1px rgba(233, 196, 122, 0.3), 0 0 16px rgba(110, 170, 80, 0.3); }
|
|
363
363
|
.btn.danger { color: #f59a8c; }
|
|
364
364
|
.btn.danger:hover { border-color: #b8402f; box-shadow: 0 0 12px rgba(240, 123, 106, 0.3); }
|
|
365
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #4a3824; background: rgba(10, 7, 4, 0.7); }
|
|
365
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #4a3824; background: rgba(10, 7, 4, 0.7); }
|
|
366
366
|
|
|
367
367
|
.models::backdrop, .news-panel::backdrop { background: rgba(6, 4, 2, 0.66); }
|
|
368
368
|
.terminal-panel { border-color: #5a4226; box-shadow: 0 0 0 1px rgba(233, 196, 122, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
|
|
@@ -249,7 +249,7 @@
|
|
|
249
249
|
}
|
|
250
250
|
.btn.primary:hover { filter: brightness(1.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 0 0 1px rgba(227, 166, 119, 0.6), 0 5px 16px rgba(20, 70, 56, 0.35); }
|
|
251
251
|
.btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
|
|
252
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
|
|
252
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
|
|
253
253
|
|
|
254
254
|
.models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--panel-strong); backdrop-filter: blur(16px); }
|
|
255
255
|
.models, .news-panel { box-shadow: var(--dialog-shadow); }
|
|
@@ -369,7 +369,7 @@
|
|
|
369
369
|
.btn.primary { border-color: #3f9a80; background: linear-gradient(#2f8a73, #1b5a4b); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 0 0 1px rgba(227, 166, 119, 0.3), 0 0 16px rgba(60, 170, 140, 0.3); }
|
|
370
370
|
.btn.danger { color: #f59a8c; }
|
|
371
371
|
.btn.danger:hover { border-color: #b8402f; box-shadow: 0 0 12px rgba(240, 123, 106, 0.3); }
|
|
372
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #3a4a3f; background: rgba(5, 8, 6, 0.7); }
|
|
372
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #3a4a3f; background: rgba(5, 8, 6, 0.7); }
|
|
373
373
|
|
|
374
374
|
.models::backdrop, .news-panel::backdrop { background: rgba(2, 5, 3, 0.66); }
|
|
375
375
|
.terminal-panel { border-color: #5a3a22; box-shadow: 0 0 0 1px rgba(227, 166, 119, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
|
package/web/skins/grove/skin.css
CHANGED
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
}
|
|
245
245
|
.btn.primary:hover { filter: brightness(1.07); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 6px 20px rgba(45, 107, 69, 0.36); }
|
|
246
246
|
.btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
|
|
247
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--paper-strong); }
|
|
247
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--paper-strong); }
|
|
248
248
|
|
|
249
249
|
.models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--paper-strong); backdrop-filter: blur(16px); }
|
|
250
250
|
.models, .news-panel { box-shadow: var(--dialog-shadow); }
|
|
@@ -371,7 +371,7 @@
|
|
|
371
371
|
.btn.primary { background: linear-gradient(135deg, #4a8c5e, #2a6340); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 18px rgba(80, 160, 105, 0.35); }
|
|
372
372
|
.btn.danger { color: #ff9a8e; }
|
|
373
373
|
.btn.danger:hover { border-color: #b8443a; box-shadow: 0 0 12px rgba(240, 122, 108, 0.3); }
|
|
374
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #2e4a3d; background: rgba(4, 10, 8, 0.7); }
|
|
374
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #2e4a3d; background: rgba(4, 10, 8, 0.7); }
|
|
375
375
|
|
|
376
376
|
.models::backdrop, .news-panel::backdrop { background: rgba(2, 6, 5, 0.66); }
|
|
377
377
|
.terminal-panel { border-color: #2e4a3d; box-shadow: 0 0 0 1px rgba(140, 210, 160, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
|
package/web/skins/guild/skin.css
CHANGED
|
@@ -480,7 +480,7 @@
|
|
|
480
480
|
}
|
|
481
481
|
.btn.primary:hover { border-color: #b8892f; filter: brightness(1.08); }
|
|
482
482
|
.btn.danger:hover { border-color: #c4625d; }
|
|
483
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .notes-text {
|
|
483
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .notes-text {
|
|
484
484
|
border-color: #dccba5; background: #fffdf8; box-shadow: inset 0 1px 2px rgba(70, 48, 12, 0.06);
|
|
485
485
|
}
|
|
486
486
|
.auth { border-color: #e4d6b6; box-shadow: var(--card-shadow); }
|
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
}
|
|
247
247
|
.btn.primary:hover { filter: brightness(1.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 20px rgba(122, 79, 232, 0.4); }
|
|
248
248
|
.btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
|
|
249
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--glass-strong); }
|
|
249
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--glass-strong); }
|
|
250
250
|
|
|
251
251
|
.models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--glass-strong); backdrop-filter: blur(16px); }
|
|
252
252
|
.models, .news-panel { box-shadow: var(--dialog-shadow); }
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
.btn.primary { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 0 18px rgba(106, 92, 240, 0.45); }
|
|
368
368
|
.btn.danger { color: #ff8c9c; }
|
|
369
369
|
.btn.danger:hover { border-color: #c23a52; box-shadow: 0 0 12px rgba(255, 107, 129, 0.3); }
|
|
370
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #26355c; background: rgba(4, 7, 16, 0.7); }
|
|
370
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #26355c; background: rgba(4, 7, 16, 0.7); }
|
|
371
371
|
|
|
372
372
|
.models::backdrop, .news-panel::backdrop { background: rgba(2, 4, 10, 0.66); }
|
|
373
373
|
.terminal-panel { border-color: #2a3a66; box-shadow: 0 0 0 1px rgba(120, 170, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
|
package/web/styles.css
CHANGED
|
@@ -743,13 +743,15 @@ a.news-link:visited { color: var(--muted); }
|
|
|
743
743
|
}
|
|
744
744
|
.notes-text:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
745
745
|
.notes-panel .sub.warn { color: var(--warn); }
|
|
746
|
+
/* Room below the status line for the field's focus ring, and for a warning that wraps to two lines. */
|
|
747
|
+
.notes-panel .dock-sub { flex-shrink: 0; padding-bottom: 8px; line-height: 1.4; }
|
|
746
748
|
@media (pointer: coarse) {
|
|
747
749
|
/* iOS zooms in on a field with smaller text when it takes focus. */
|
|
748
750
|
.notes-text { font-size: 16px; }
|
|
749
751
|
}
|
|
750
752
|
|
|
751
753
|
.history-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 14px 0; }
|
|
752
|
-
.history-tools input[type="search"] {
|
|
754
|
+
.history-tools input[type="search"], .folder-new input {
|
|
753
755
|
flex: 1 1 220px; min-width: 0; padding: 7px 10px; border-radius: var(--radius); border: 1px solid var(--border);
|
|
754
756
|
background: var(--surface); color: var(--text); font: inherit; font-size: 0.9rem;
|
|
755
757
|
}
|
|
@@ -1035,6 +1037,9 @@ a.btn.github-out::after {
|
|
|
1035
1037
|
.folder-crumb:focus-visible, .folder-row:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
1036
1038
|
.folder-places, .folder-roots { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
1037
1039
|
.folder-places .btn { min-height: 36px; }
|
|
1040
|
+
.folder-new-open { margin-left: auto; }
|
|
1041
|
+
.folder-new { flex-wrap: nowrap; }
|
|
1042
|
+
.folder-new input { flex: 1; min-width: 0; }
|
|
1038
1043
|
.folder-list { display: flex; flex-direction: column; }
|
|
1039
1044
|
.folder-row {
|
|
1040
1045
|
display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px; border: 0; border-bottom: 1px solid var(--border);
|
|
@@ -1047,6 +1052,9 @@ a.btn.github-out::after {
|
|
|
1047
1052
|
.folder-footer { justify-content: space-between; }
|
|
1048
1053
|
.folder-current { flex: 1; min-width: 0; font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.8rem; overflow-wrap: anywhere; }
|
|
1049
1054
|
.folder-footer .btn { min-height: 42px; }
|
|
1055
|
+
@media (pointer: coarse), (max-width: 640px) {
|
|
1056
|
+
.folder-places .btn, .folder-new .btn, .folder-new input, .folder-footer .btn, #folder-cancel { min-height: 44px; }
|
|
1057
|
+
}
|
|
1050
1058
|
.cwd-recent {
|
|
1051
1059
|
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 5; margin: 0; padding: 4px; list-style: none;
|
|
1052
1060
|
border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
|
|
@@ -1056,4 +1064,5 @@ a.btn.github-out::after {
|
|
|
1056
1064
|
color: var(--text); font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; overflow-wrap: anywhere; cursor: pointer;
|
|
1057
1065
|
}
|
|
1058
1066
|
.cwd-recent-option:hover, .cwd-recent-option[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
|
1059
|
-
@media (pointer: coarse) { .cwd-recent-option { min-height: 46px; } }
|
|
1067
|
+
@media (pointer: coarse), (max-width: 640px) { .cwd-recent-option { min-height: 46px; } }
|
|
1068
|
+
.cwd-recent.above { top: auto; bottom: calc(100% + 4px); }
|