copyhub-cli 1.0.1 → 1.0.3

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/.env.example CHANGED
@@ -10,6 +10,7 @@
10
10
  # copyhub start opens the Electron overlay by default; set to 1 for clipboard + Sheet only:
11
11
  # COPYHUB_START_NO_OVERLAY=1
12
12
 
13
+ # OAuth Client ID / Secret: optional here — running copyhub login opens a browser wizard that saves them to ~/.copyhub/config.json instead.
13
14
  COPYHUB_GOOGLE_CLIENT_ID=
14
15
  COPYHUB_GOOGLE_CLIENT_SECRET=
15
16
  COPYHUB_OAUTH_REDIRECT_PORT=19999
package/README.md CHANGED
@@ -45,6 +45,68 @@ Or run without linking:
45
45
  node src/cli.js <command>
46
46
  ```
47
47
 
48
+ ## Updating
49
+
50
+ Your settings (`~/.copyhub/config.json`, tokens, history) are kept when you upgrade the CLI.
51
+
52
+ **Recommended:** stop the background daemon before upgrading so Electron can reinstall cleanly; after upgrading start again:
53
+
54
+ ```bash
55
+ copyhub stop
56
+ ```
57
+
58
+ *(Skip `stop` if you only run `--foreground` or `overlay` and nothing is in the background.)*
59
+
60
+ ### Global install (`npm install -g copyhub-cli`)
61
+
62
+ Check what npm considers latest vs what you have:
63
+
64
+ ```bash
65
+ npm view copyhub-cli version
66
+ ```
67
+
68
+ Upgrade to the latest published release:
69
+
70
+ ```bash
71
+ npm install -g copyhub-cli@latest
72
+ ```
73
+
74
+ Alternatively:
75
+
76
+ ```bash
77
+ npm update -g copyhub-cli
78
+ ```
79
+
80
+ Restart CopyHub:
81
+
82
+ ```bash
83
+ copyhub start
84
+ ```
85
+
86
+ To see the installed package version: `npm list -g copyhub-cli --depth=0`.
87
+
88
+ ### From source
89
+
90
+ Pull latest commits and reinstall modules:
91
+
92
+ ```bash
93
+ copyhub stop
94
+ git pull
95
+ npm install
96
+ ```
97
+
98
+ If you previously ran **`npm link`**, linking stays tied to this folder — after `npm install` you usually **do not** need to link again unless npm warns otherwise:
99
+
100
+ ```bash
101
+ npm link
102
+ ```
103
+
104
+ Then:
105
+
106
+ ```bash
107
+ copyhub start
108
+ ```
109
+
48
110
  ## Google Cloud setup
49
111
 
50
112
  1. Enable **[Google Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com)** on your OAuth project.
@@ -54,13 +116,13 @@ node src/cli.js <command>
54
116
  http://127.0.0.1:19999/oauth2callback
55
117
  ```
56
118
 
57
- 3. Copy `.env.example` to `.env` and set:
119
+ 3. Provide OAuth credentials in **any one** of these ways:
58
120
 
59
- - `COPYHUB_GOOGLE_CLIENT_ID`
60
- - `COPYHUB_GOOGLE_CLIENT_SECRET`
61
- - Optionally `COPYHUB_OAUTH_REDIRECT_PORT` (default **19999**)
121
+ - **Recommended for first setup:** run **`copyhub login`** with no secrets configured — your browser opens a **localhost wizard** where you paste **Client ID** and **Client secret** (the same values as `COPYHUB_GOOGLE_CLIENT_ID` / `COPYHUB_GOOGLE_CLIENT_SECRET`); they are stored in `~/.copyhub/config.json`.
62
122
 
63
- Alternatively, store credentials in `~/.copyhub/config.json` via:
123
+ - Copy `.env.example` to `.env` and set `COPYHUB_GOOGLE_CLIENT_ID`, `COPYHUB_GOOGLE_CLIENT_SECRET`, and optionally `COPYHUB_OAUTH_REDIRECT_PORT` (default **19999**).
124
+
125
+ - Or run **`copyhub config`**:
64
126
 
65
127
  ```bash
66
128
  copyhub config --client-id "<ID>" --client-secret "<SECRET>" [--sheet-id "<SPREADSHEET_ID>"] [--redirect-port 19999]
@@ -68,13 +130,16 @@ copyhub config --client-id "<ID>" --client-secret "<SECRET>" [--sheet-id "<SPREA
68
130
 
69
131
  ## First run
70
132
 
71
- 1. **Login** (opens the browser for OAuth, then a setup page):
133
+ 1. **Login** opens the browser:
72
134
 
73
135
  ```bash
74
136
  copyhub login
75
137
  ```
76
138
 
77
- 2. On the setup page, enter your **Spreadsheet ID** (from the URL `…/d/<SPREADSHEET_ID>/edit`), choose **platform** (Windows / macOS / Linux) for shortcut hints, set the **overlay accelerator** if you want, and save.
139
+ - If Client ID / Secret are **not** already in `.env` or `~/.copyhub/config.json`, the first screen collects them (**Google OAuth credentials** page). Submit it to save into config.
140
+ - After Google sign-in, another setup page asks for **Spreadsheet ID**, **platform**, and optional **overlay shortcut**.
141
+
142
+ 2. On that spreadsheet/setup page, enter your **Spreadsheet ID** (from the URL `…/d/<SPREADSHEET_ID>/edit`), choose **platform** (Windows / macOS / Linux) for shortcut hints, set the **overlay accelerator** if you want, and save.
78
143
 
79
144
  3. **Start** the background watcher (clipboard + Sheets + overlay by default):
80
145
 
@@ -116,7 +181,7 @@ copyhub --help
116
181
  | Command | What it does |
117
182
  |---------|----------------|
118
183
  | `copyhub config` | Writes OAuth client ID/secret (and optional Sheet ID, redirect port) to `~/.copyhub/config.json`. `--client-id` and `--client-secret` are required. |
119
- | `copyhub login` | Opens browser for Google OAuth, then the setup page (spreadsheet ID, platform, overlay shortcut). |
184
+ | `copyhub login` | Opens browser: localhost wizard for Client ID/secret if missing, then Google OAuth, then spreadsheet/platform setup. |
120
185
  | `copyhub logout` | Deletes saved OAuth tokens (`~/.copyhub/tokens.json`). |
121
186
  | `copyhub status` | Prints OAuth config source, sheet target, tokens, overlay settings, and whether the background daemon is running. |
122
187
  | `copyhub start` | Starts clipboard watcher + optional Sheets sync + Electron overlay in the **background** (closing the terminal does not stop it). Only one instance at a time. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copyhub-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "CopyHub — clipboard, local history, Google Sheets sync (OAuth). Windows, macOS, Linux.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  import { killDaemonTree } from './stop-process.js';
32
32
  import { ensureDir } from './storage.js';
33
33
  import { runCopyhubDaemon } from './start-daemon-logic.js';
34
+ import { wipeCopyhubDirectory } from './wipe-data.js';
34
35
 
35
36
  const CLI_JS = fileURLToPath(new URL('./cli.js', import.meta.url));
36
37
 
@@ -72,7 +73,7 @@ program
72
73
  program
73
74
  .command('login')
74
75
  .description(
75
- `Google sign-in (OAuth Sheets), then Spreadsheet ID setup page — port ${DEFAULT_OAUTH_REDIRECT_PORT} or ${ENV_OAUTH_REDIRECT_PORT}`,
76
+ `OAuth Sheets flow (opens browser). If Client ID/Secret are missing, a localhost wizard saves them first callback port ${DEFAULT_OAUTH_REDIRECT_PORT} or ${ENV_OAUTH_REDIRECT_PORT}`,
76
77
  )
77
78
  .action(async () => {
78
79
  await runLoginFlow();
@@ -86,6 +87,35 @@ program
86
87
  console.log(`Removed tokens: ${TOKENS_PATH}`);
87
88
  });
88
89
 
90
+ program
91
+ .command('reset')
92
+ .description(
93
+ 'Stop background daemon if running; delete ~/.copyhub entirely (config, tokens, history, run state). Requires --yes',
94
+ )
95
+ .option('--yes', 'Required confirmation flag (destructive)')
96
+ .action(async (opts) => {
97
+ if (!opts.yes) {
98
+ console.error(
99
+ 'Refusing to wipe without --yes. Deletes ~/.copyhub (Windows: %USERPROFILE%\\.copyhub). Example: copyhub reset --yes',
100
+ );
101
+ process.exit(1);
102
+ }
103
+ pruneStaleRunState();
104
+ const s = readRunState();
105
+ if (s?.pid && isPidAlive(s.pid)) {
106
+ killDaemonTree(s.pid);
107
+ }
108
+ try {
109
+ await wipeCopyhubDirectory();
110
+ } catch (e) {
111
+ console.error((/** @type {Error} */ (e)).message || String(e));
112
+ process.exit(1);
113
+ }
114
+ console.log(`Removed CopyHub data directory: ${DIR}`);
115
+ console.log('.env is not changed — remove OAuth vars there if you want.');
116
+ console.log('If an overlay window was open separately, close it manually.');
117
+ });
118
+
89
119
  program
90
120
  .command('overlay')
91
121
  .description(
@@ -163,7 +193,7 @@ program
163
193
  if (!cfg) {
164
194
  console.log('OAuth config: missing');
165
195
  console.log(
166
- ` Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env (see .env.example), or run: copyhub config`,
196
+ ` Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env (see .env.example), run copyhub config, or copyhub login (browser wizard)`,
167
197
  );
168
198
  } else {
169
199
  const srcLabel =
@@ -333,6 +363,7 @@ program
333
363
  .action(() => {
334
364
  console.log(`copyhub config [--client-id ID] [--client-secret SEC] [--redirect-port P] [--sheet-id ID]
335
365
  copyhub login | copyhub logout | copyhub status
366
+ copyhub reset --yes (delete ~/.copyhub — stop daemon first is recommended)
336
367
  copyhub start [--no-sheet] [--no-overlay] [--foreground]
337
368
  Default runs in background (terminal can close). Single instance — second start is blocked.
338
369
  copyhub list (ls) | copyhub stop
package/src/config.js CHANGED
@@ -22,6 +22,26 @@ function parseRedirectPortFromEnv() {
22
22
  return n;
23
23
  }
24
24
 
25
+ /**
26
+ * Port for the OAuth HTTP listener (env wins, then saved config, then default).
27
+ * Does not require Client ID / Secret (used before credential bootstrap).
28
+ */
29
+ export function resolveOAuthListenPort() {
30
+ const envPort = parseRedirectPortFromEnv();
31
+ if (envPort != null) return envPort;
32
+ if (existsSync(CONFIG_PATH)) {
33
+ try {
34
+ const j = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
35
+ if (typeof j.redirectPort === 'number' && Number.isFinite(j.redirectPort)) {
36
+ return j.redirectPort;
37
+ }
38
+ } catch {
39
+ /* ignore */
40
+ }
41
+ }
42
+ return DEFAULT_OAUTH_REDIRECT_PORT;
43
+ }
44
+
25
45
  /** Both Client ID and Secret come from environment (or .env). */
26
46
  export function hasOAuthCredentialsInEnv() {
27
47
  const id = process.env[ENV_GOOGLE_CLIENT_ID]?.trim();
package/src/oauth.js CHANGED
@@ -8,7 +8,9 @@ import {
8
8
  DEFAULT_OAUTH_REDIRECT_PORT,
9
9
  ENV_GOOGLE_CLIENT_ID,
10
10
  ENV_GOOGLE_CLIENT_SECRET,
11
+ ENV_OAUTH_REDIRECT_PORT,
11
12
  mergeConfigPartial,
13
+ resolveOAuthListenPort,
12
14
  loadSheetSyncTarget,
13
15
  loadOverlayAcceleratorFromConfigSync,
14
16
  loadOverlayPlatformFromConfigSync,
@@ -34,7 +36,7 @@ export async function getAuthorizedClient() {
34
36
  const cfg = await loadConfig();
35
37
  if (!cfg) {
36
38
  throw new Error(
37
- `OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} (.env or environment), or run: copyhub config --client-id ID --client-secret SECRET`,
39
+ `OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} (.env or environment), run copyhub config, or copyhub login (browser wizard).`,
38
40
  );
39
41
  }
40
42
  const client = createOAuthClient(cfg);
@@ -75,6 +77,256 @@ function escapeHtml(s) {
75
77
  .replace(/'/g, '&#39;');
76
78
  }
77
79
 
80
+ /**
81
+ * @param {string} bootstrapToken
82
+ * @param {number} listenPort
83
+ */
84
+ function credentialSetupPageHtml(bootstrapToken, listenPort) {
85
+ const tVal = escapeHtml(bootstrapToken);
86
+ const callbackUri = `http://127.0.0.1:${listenPort}/oauth2callback`;
87
+ return `<!DOCTYPE html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="utf-8" />
91
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
92
+ <title>CopyHub — Google OAuth credentials</title>
93
+ <style>
94
+ :root {
95
+ --text: #141824;
96
+ --muted: #5a6272;
97
+ --line: #e2e8f1;
98
+ --accent: #2563eb;
99
+ --accent-soft: #eff6ff;
100
+ --radius: 14px;
101
+ --shadow: 0 4px 24px rgba(20, 24, 36, 0.08), 0 1px 3px rgba(20, 24, 36, 0.04);
102
+ }
103
+ * { box-sizing: border-box; }
104
+ body {
105
+ margin: 0;
106
+ min-height: 100vh;
107
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
108
+ background: linear-gradient(160deg, #e8eef9 0%, #f5f7fb 45%, #eef2f8 100%);
109
+ color: var(--text);
110
+ padding: 32px 16px 48px;
111
+ line-height: 1.55;
112
+ }
113
+ .wrap { max-width: 520px; margin: 0 auto; }
114
+ .brand {
115
+ font-size: 13px;
116
+ font-weight: 600;
117
+ letter-spacing: 0.06em;
118
+ text-transform: uppercase;
119
+ color: var(--accent);
120
+ margin-bottom: 8px;
121
+ }
122
+ h1 { font-size: 1.55rem; font-weight: 700; margin: 0 0 8px; letter-spacing: -0.02em; }
123
+ .sub { color: var(--muted); font-size: 15px; margin-bottom: 24px; }
124
+ .card {
125
+ background: #fff;
126
+ border-radius: var(--radius);
127
+ box-shadow: var(--shadow);
128
+ border: 1px solid rgba(255,255,255,0.8);
129
+ padding: 26px 24px;
130
+ margin-bottom: 18px;
131
+ }
132
+ label.field-label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 8px; }
133
+ input[type="password"], input[type="text"] {
134
+ width: 100%;
135
+ padding: 12px 14px;
136
+ font-size: 15px;
137
+ border: 1px solid var(--line);
138
+ border-radius: 10px;
139
+ background: #fafbfd;
140
+ }
141
+ input:focus {
142
+ outline: none;
143
+ border-color: var(--accent);
144
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
145
+ background: #fff;
146
+ }
147
+ .hint { font-size: 13px; color: var(--muted); margin-top: 10px; line-height: 1.5; }
148
+ .hint code {
149
+ font-size: 12px;
150
+ background: var(--accent-soft);
151
+ color: #1d4ed8;
152
+ padding: 2px 7px;
153
+ border-radius: 6px;
154
+ font-weight: 500;
155
+ }
156
+ button.submit {
157
+ width: 100%;
158
+ margin-top: 20px;
159
+ padding: 14px 20px;
160
+ font-size: 16px;
161
+ font-weight: 600;
162
+ color: #fff;
163
+ background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
164
+ border: none;
165
+ border-radius: 12px;
166
+ cursor: pointer;
167
+ box-shadow: 0 2px 8px rgba(37, 99, 235, 0.35);
168
+ }
169
+ button.submit:hover { filter: brightness(1.05); }
170
+ .callback-box {
171
+ font-size: 13px;
172
+ padding: 12px 14px;
173
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
174
+ border-radius: 10px;
175
+ border: 1px solid var(--line);
176
+ word-break: break-all;
177
+ }
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <div class="wrap">
182
+ <div class="brand">CopyHub</div>
183
+ <h1>Google Cloud OAuth</h1>
184
+ <p class="sub">Paste your OAuth 2.0 Client ID and Client Secret (same as <code>${ENV_GOOGLE_CLIENT_ID}</code> / <code>${ENV_GOOGLE_CLIENT_SECRET}</code> in <code>.env</code>). Stored in <code>~/.copyhub/config.json</code>.</p>
185
+
186
+ <div class="card">
187
+ <p class="hint" style="margin-top:0;"><strong>Authorized redirect URI</strong> in Google Cloud Console must include:</p>
188
+ <div class="callback-box"><code>${escapeHtml(callbackUri)}</code></div>
189
+ <p class="hint">Port comes from <code>${ENV_OAUTH_REDIRECT_PORT}</code> (currently <strong>${listenPort}</strong>) or your saved config.</p>
190
+ </div>
191
+
192
+ <form method="POST" action="/credentials">
193
+ <input type="hidden" name="t" value="${tVal}" />
194
+ <div class="card">
195
+ <label class="field-label" for="cid">Client ID</label>
196
+ <input id="cid" type="text" name="clientId" autocomplete="off" spellcheck="false" required />
197
+ <label class="field-label" for="csec" style="margin-top:16px;">Client secret</label>
198
+ <input id="csec" type="password" name="clientSecret" autocomplete="new-password" spellcheck="false" required />
199
+ </div>
200
+ <button type="submit" class="submit">Save and continue to Google sign-in</button>
201
+ </form>
202
+ </div>
203
+ </body>
204
+ </html>`;
205
+ }
206
+
207
+ const credentialSavedHtml = `<!DOCTYPE html>
208
+ <html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>CopyHub</title>
209
+ <style>
210
+ body{font-family:system-ui,sans-serif;min-height:100vh;margin:0;display:flex;align-items:center;justify-content:center;background:linear-gradient(160deg,#e8eef9,#f5f7fb);padding:24px;}
211
+ .box{background:#fff;padding:32px 36px;border-radius:16px;box-shadow:0 8px 32px rgba(20,24,36,.1);max-width:420px;text-align:center;line-height:1.65;color:#141824;}
212
+ .box h2{margin:0 0 12px;font-size:1.25rem;}
213
+ .box p{margin:.6rem 0;color:#5a6272;font-size:15px;}
214
+ .box code{background:#eff6ff;color:#1d4ed8;padding:2px 8px;border-radius:6px;font-size:13px;}
215
+ </style></head>
216
+ <body><div class="box"><h2>Credentials saved</h2>
217
+ <p>The login flow will open Google next (this tab can stay open).</p></div></body></html>`;
218
+
219
+ /**
220
+ * Localhost wizard when Client ID / Secret are missing from env and config file.
221
+ * @returns {Promise<void>}
222
+ */
223
+ async function runCredentialBootstrap() {
224
+ const listenPort = resolveOAuthListenPort();
225
+ await new Promise((resolve, reject) => {
226
+ /** @type {string | null} */
227
+ let bootstrapToken = randomBytes(24).toString('hex');
228
+ let settled = false;
229
+
230
+ const finish = () => {
231
+ if (settled) return;
232
+ settled = true;
233
+ server.close(() => resolve(undefined));
234
+ };
235
+
236
+ const server = createServer(async (req, res) => {
237
+ try {
238
+ if (!req.url) {
239
+ res.writeHead(400);
240
+ res.end('Bad request');
241
+ return;
242
+ }
243
+ const u = new URL(req.url, `http://127.0.0.1:${listenPort}`);
244
+
245
+ if (u.pathname === '/credentials' && req.method === 'GET') {
246
+ const t = u.searchParams.get('t');
247
+ if (!bootstrapToken || t !== bootstrapToken) {
248
+ res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
249
+ res.end('<p>Invalid session. Run <code>copyhub login</code> again.</p>');
250
+ return;
251
+ }
252
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
253
+ res.end(credentialSetupPageHtml(bootstrapToken, listenPort));
254
+ return;
255
+ }
256
+
257
+ if (u.pathname === '/credentials' && req.method === 'POST') {
258
+ let body = '';
259
+ try {
260
+ body = await readRequestBody(req);
261
+ } catch {
262
+ res.writeHead(413);
263
+ res.end('Payload too large');
264
+ return;
265
+ }
266
+ const params = new URLSearchParams(body);
267
+ const t = params.get('t');
268
+ if (!bootstrapToken || t !== bootstrapToken) {
269
+ res.writeHead(403);
270
+ res.end('Forbidden');
271
+ return;
272
+ }
273
+ const clientId = (params.get('clientId') || '').trim();
274
+ const clientSecret = (params.get('clientSecret') || '').trim();
275
+ if (!clientId || !clientSecret) {
276
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
277
+ res.end('<p>Client ID and Client secret are required.</p>');
278
+ return;
279
+ }
280
+ try {
281
+ await mergeConfigPartial({ clientId, clientSecret });
282
+ } catch {
283
+ res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
284
+ res.end('<p>Could not write config. Check write permissions on ~/.copyhub/</p>');
285
+ return;
286
+ }
287
+ bootstrapToken = null;
288
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
289
+ res.end(credentialSavedHtml);
290
+ setTimeout(finish, 400);
291
+ return;
292
+ }
293
+
294
+ res.writeHead(404);
295
+ res.end('Not found');
296
+ } catch (e) {
297
+ res.writeHead(500);
298
+ res.end('Server error');
299
+ server.close(() => reject(/** @type {Error} */ (e)));
300
+ }
301
+ });
302
+
303
+ const idleMs = 20 * 60 * 1000;
304
+ const idleTimer = setTimeout(() => {
305
+ if (!settled) {
306
+ console.warn('Credential setup idle timeout (20 minutes).');
307
+ finish();
308
+ }
309
+ }, idleMs);
310
+ server.on('close', () => clearTimeout(idleTimer));
311
+
312
+ server.on('error', (err) => {
313
+ if (!settled) reject(err);
314
+ });
315
+
316
+ server.listen(listenPort, '127.0.0.1', async () => {
317
+ const credUrl = `http://127.0.0.1:${listenPort}/credentials?t=${encodeURIComponent(bootstrapToken)}`;
318
+ console.log('Opening browser for Google OAuth credentials (localhost wizard)...');
319
+ console.log(`If it does not open: ${credUrl}`);
320
+ try {
321
+ await open(credUrl);
322
+ } catch {
323
+ console.log('Open this URL manually:');
324
+ console.log(credUrl);
325
+ }
326
+ });
327
+ });
328
+ }
329
+
78
330
  /** Shortcut presets (Electron Accelerator) per platform — embedded as JSON in the setup page. */
79
331
  const PLATFORM_PRESETS = {
80
332
  win: [
@@ -423,10 +675,25 @@ body{font-family:system-ui,sans-serif;min-height:100vh;margin:0;display:flex;ali
423
675
  * @returns {Promise<void>}
424
676
  */
425
677
  export async function runLoginFlow() {
426
- const cfg = await loadConfig();
678
+ let cfg = await loadConfig();
679
+ if (!cfg) {
680
+ const listenPort = resolveOAuthListenPort();
681
+ try {
682
+ await runCredentialBootstrap();
683
+ } catch (e) {
684
+ const code = /** @type {NodeJS.ErrnoException} */ (e)?.code;
685
+ if (code === 'EADDRINUSE') {
686
+ throw new Error(
687
+ `Port ${listenPort} is already in use. Stop the other process or set ${ENV_OAUTH_REDIRECT_PORT} to a free port.`,
688
+ );
689
+ }
690
+ throw /** @type {Error} */ (e);
691
+ }
692
+ cfg = await loadConfig();
693
+ }
427
694
  if (!cfg) {
428
695
  throw new Error(
429
- `Not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env, or run: copyhub config --client-id ... --client-secret ...`,
696
+ `OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env, run copyhub config, or complete the browser credential wizard.`,
430
697
  );
431
698
  }
432
699
  const port = cfg.redirectPort ?? DEFAULT_OAUTH_REDIRECT_PORT;
@@ -0,0 +1,79 @@
1
+ import { google } from 'googleapis';
2
+ import { getAuthorizedClient } from './oauth.js';
3
+ import { loadSheetSyncTarget } from './config.js';
4
+ import { formatGoogleSheetUserMessage } from './sheet-api-errors.js';
5
+
6
+ /** @param {unknown} v */
7
+ function cellToString(v) {
8
+ if (v == null) return '';
9
+ if (typeof v === 'string') return v;
10
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
11
+ if (v instanceof Date) return v.toISOString();
12
+ return String(v);
13
+ }
14
+
15
+ /** @param {string} tabName */
16
+ function escapeTabName(tabName) {
17
+ return /[^A-Za-z0-9_]/.test(tabName)
18
+ ? `'${tabName.replace(/'/g, "''")}'`
19
+ : tabName;
20
+ }
21
+
22
+ /** @param {number} daysAgo */
23
+ function overlayDailyTabNameDaysAgo(daysAgo) {
24
+ const d = new Date();
25
+ d.setDate(d.getDate() - daysAgo);
26
+ const y = d.getFullYear();
27
+ const m = String(d.getMonth() + 1).padStart(2, '0');
28
+ const day = String(d.getDate()).padStart(2, '0');
29
+ return `COPYHUB-${y}-${m}-${day}`;
30
+ }
31
+
32
+ /**
33
+ * Clipboard rows from one COPYHUB-YYYY-MM-DD tab (newest timestamps first).
34
+ * Reads used cells in A:B from row 2 onward (append puts newer rows at the bottom).
35
+ * @param {number} daysAgo 0 = today
36
+ * @param {number} [maxRowsCap] Keep only this many newest rows (after sort).
37
+ */
38
+ export async function fetchOverlayDailyTabRows(daysAgo, maxRowsCap = 500) {
39
+ const day = Math.min(Math.max(Number(daysAgo), 0), 90);
40
+ const cap = Math.min(Math.max(Number(maxRowsCap), 1), 2000);
41
+
42
+ const target = await loadSheetSyncTarget();
43
+ if (!target) return [];
44
+
45
+ const auth = await getAuthorizedClient();
46
+ if (!auth.credentials.refresh_token && !auth.credentials.access_token) {
47
+ return [];
48
+ }
49
+
50
+ const sheets = google.sheets({ version: 'v4', auth });
51
+ const tab = overlayDailyTabNameDaysAgo(day);
52
+ const range = `${escapeTabName(tab)}!A2:B`;
53
+
54
+ try {
55
+ const res = await sheets.spreadsheets.values.get({
56
+ spreadsheetId: target.spreadsheetId,
57
+ range,
58
+ valueRenderOption: 'FORMATTED_VALUE',
59
+ });
60
+
61
+ const values = res.data.values ?? [];
62
+
63
+ /** @type {Array<{ ts: string, text: string, synced: boolean }>} */
64
+ const items = [];
65
+ for (const row of values) {
66
+ if (!Array.isArray(row) || row.length < 2) continue;
67
+ const ts = cellToString(row[0]).trim();
68
+ const text = cellToString(row[1]).trim();
69
+ if (!text) continue;
70
+ if (/^(time|thời gian)$/i.test(ts)) continue;
71
+ items.push({ ts: ts || '', text, synced: true });
72
+ }
73
+
74
+ items.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
75
+ return items.slice(0, cap);
76
+ } catch (e) {
77
+ throw new Error(formatGoogleSheetUserMessage(e));
78
+ }
79
+ }
package/src/sheets.js CHANGED
@@ -7,7 +7,7 @@ import { formatGoogleSheetUserMessage } from './sheet-api-errors.js';
7
7
  /**
8
8
  * @param {string} tabName
9
9
  */
10
- function a1RangeForTab(tabName) {
10
+ export function a1RangeForTab(tabName) {
11
11
  const escaped = /[^A-Za-z0-9_]/.test(tabName)
12
12
  ? `'${tabName.replace(/'/g, "''")}'`
13
13
  : tabName;
@@ -1,4 +1,4 @@
1
- import { appendHistory } from './storage.js';
1
+ import { appendHistory, isDuplicateOfLatestHistory } from './storage.js';
2
2
  import { startClipboardWatcher } from './clipboard-watcher.js';
3
3
  import { appendClipboardToSheet } from './sheets.js';
4
4
  import { loadTokens } from './tokens.js';
@@ -73,6 +73,9 @@ export async function runCopyhubDaemon(opts, io = console) {
73
73
  let lastSheetLogAt = 0;
74
74
 
75
75
  const watcher = startClipboardWatcher(async (text) => {
76
+ if (isDuplicateOfLatestHistory(text)) {
77
+ return;
78
+ }
76
79
  let synced = false;
77
80
  if (useSheet && sheetTarget && (tokens?.refresh_token || tokens?.access_token)) {
78
81
  try {
package/src/storage.js CHANGED
@@ -57,3 +57,14 @@ export function readRecentHistorySync(maxLines = 200) {
57
57
  }
58
58
  return out.reverse();
59
59
  }
60
+
61
+ /**
62
+ * True when `text` equals the latest saved history row (skip consecutive identical copies).
63
+ * @param {string} text
64
+ */
65
+ export function isDuplicateOfLatestHistory(text) {
66
+ if (typeof text !== 'string' || text.length === 0) return false;
67
+ const recent = readRecentHistorySync(1);
68
+ const last = recent[0];
69
+ return typeof last?.text === 'string' && last.text === text;
70
+ }
@@ -0,0 +1,10 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { DIR } from './paths.js';
4
+
5
+ /** Delete the entire ~/.copyhub directory (all local CopyHub data). */
6
+ export async function wipeCopyhubDirectory() {
7
+ if (existsSync(DIR)) {
8
+ await rm(DIR, { recursive: true, force: true });
9
+ }
10
+ }
package/ui/main.mjs CHANGED
@@ -13,7 +13,12 @@ import {
13
13
  nativeImage,
14
14
  } from 'electron';
15
15
  import { readRecentHistorySync } from '../src/storage.js';
16
- import { loadOverlayAcceleratorFromConfigSync } from '../src/config.js';
16
+ import {
17
+ loadOverlayAcceleratorFromConfigSync,
18
+ loadSheetSyncTarget,
19
+ } from '../src/config.js';
20
+ import { loadTokens } from '../src/tokens.js';
21
+ import { fetchOverlayDailyTabRows } from '../src/sheet-overlay-history.js';
17
22
 
18
23
  const gotLock = app.requestSingleInstanceLock();
19
24
  if (!gotLock) {
@@ -38,8 +43,9 @@ function normalizeAccelerator(raw) {
38
43
  const DEFAULT_ACCEL = 'CommandOrControl+Shift+H';
39
44
  const HIDE_ON_START = process.env.COPYHUB_OVERLAY_HIDE_ON_START === '1';
40
45
 
41
- /** Overlay width (~70% of 460px baseline). */
42
- const OVERLAY_WIDTH = Math.round(460 * 0.7);
46
+ /** Overlay size (slightly larger than earlier ~70% width). */
47
+ const OVERLAY_WIDTH = Math.round(460 * 0.84);
48
+ const OVERLAY_HEIGHT = 590;
43
49
 
44
50
  /** For UI / IPC: registered shortcut and raw value from .env */
45
51
  let overlayHotkeyMeta = {
@@ -53,6 +59,27 @@ let tray = null;
53
59
  /** Avoid hiding immediately after show (WM quirks). */
54
60
  let blurHideEnabled = false;
55
61
 
62
+ /**
63
+ * After showing the overlay, enable blur→hide after a short grace period so clicks outside close it reliably.
64
+ * @param {BrowserWindow} w
65
+ */
66
+ function armBlurHideEnable(w) {
67
+ if (STICKY_NO_BLUR || !w || w.isDestroyed()) return;
68
+ blurHideEnabled = false;
69
+ let armed = false;
70
+ const arm = () => {
71
+ if (armed || !w || w.isDestroyed()) return;
72
+ armed = true;
73
+ setTimeout(() => {
74
+ if (!STICKY_NO_BLUR && w && !w.isDestroyed()) {
75
+ blurHideEnabled = true;
76
+ }
77
+ }, 320);
78
+ };
79
+ w.once('focus', arm);
80
+ setTimeout(arm, 420);
81
+ }
82
+
56
83
  /**
57
84
  * Stay above other apps: screen-saver level (highest in Electron), moveTop, all workspaces.
58
85
  * @param {BrowserWindow} w
@@ -85,7 +112,7 @@ function applyAlwaysOnTopStack(w) {
85
112
  function createWindow() {
86
113
  win = new BrowserWindow({
87
114
  width: OVERLAY_WIDTH,
88
- height: 540,
115
+ height: OVERLAY_HEIGHT,
89
116
  alwaysOnTop: true,
90
117
  show: false,
91
118
  /** Frameless: no title bar + menu (Windows/macOS). */
@@ -138,10 +165,8 @@ function createWindow() {
138
165
  applyAlwaysOnTopStack(win);
139
166
  win.focus();
140
167
  win.webContents.send('overlay:open');
141
- setTimeout(() => {
142
- applyAlwaysOnTopStack(win);
143
- blurHideEnabled = true;
144
- }, 800);
168
+ setTimeout(() => applyAlwaysOnTopStack(win), 120);
169
+ armBlurHideEnable(win);
145
170
  });
146
171
  }
147
172
 
@@ -180,10 +205,8 @@ function toggleOverlay() {
180
205
  applyAlwaysOnTopStack(win);
181
206
  win.focus();
182
207
  win.webContents.send('overlay:open');
183
- setTimeout(() => {
184
- applyAlwaysOnTopStack(win);
185
- blurHideEnabled = true;
186
- }, 800);
208
+ setTimeout(() => applyAlwaysOnTopStack(win), 120);
209
+ armBlurHideEnable(win);
187
210
  }
188
211
  }
189
212
 
@@ -222,6 +245,169 @@ function registerHotkeys() {
222
245
  return { accelerator: '', usedFallback: false };
223
246
  }
224
247
 
248
+ function mergeHistoryForOverlay(localItems, sheetItems, cap) {
249
+ const seen = new Set();
250
+ /** @type {typeof localItems} */
251
+ const out = [];
252
+ /** Sheet rows first so duplicates dedupe keeps sheet metadata when timestamps tie. */
253
+ const combined = [...sheetItems, ...localItems];
254
+ combined.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
255
+ for (const it of combined) {
256
+ const key = `${it.ts}\u0000${it.text}`;
257
+ if (seen.has(key)) continue;
258
+ seen.add(key);
259
+ out.push(it);
260
+ }
261
+ return out.slice(0, cap);
262
+ }
263
+
264
+ /** @type {{ merged: Array<{ ts: string, text: string, synced: boolean }> }} */
265
+ const historyMergedCache = {
266
+ merged: [],
267
+ };
268
+
269
+ /** Recent local lines only — Sheet supplies older / cross-device rows so they are not crowded out. */
270
+ const HISTORY_LOCAL_LINES = 700;
271
+ /** Max merged entries after dedupe (pagination slices this list). */
272
+ const HISTORY_MERGE_CAP = 4000;
273
+
274
+ /** @type {{ sheetFetched: number, sheetHint: string }} */
275
+ let lastHistorySheetMeta = { sheetFetched: 0, sheetHint: '' };
276
+
277
+ /** Sequential Sheet fetch: one daily tab per step until overlay has enough merged rows. */
278
+ let sheetIncrementalState = {
279
+ accumulatedItems: [],
280
+ nextDaysAgo: 0,
281
+ daysBackLimit: 30,
282
+ exhausted: false,
283
+ maxRowsPerTab: 500,
284
+ };
285
+
286
+ function resetSheetIncrementalState() {
287
+ sheetIncrementalState = {
288
+ accumulatedItems: [],
289
+ nextDaysAgo: 0,
290
+ daysBackLimit: 30,
291
+ exhausted: false,
292
+ maxRowsPerTab: 500,
293
+ };
294
+ }
295
+
296
+ async function fetchNextDailyTabIntoState() {
297
+ if (sheetIncrementalState.exhausted) return;
298
+ if (sheetIncrementalState.nextDaysAgo > sheetIncrementalState.daysBackLimit) {
299
+ sheetIncrementalState.exhausted = true;
300
+ return;
301
+ }
302
+ try {
303
+ const items = await fetchOverlayDailyTabRows(
304
+ sheetIncrementalState.nextDaysAgo,
305
+ sheetIncrementalState.maxRowsPerTab,
306
+ );
307
+ sheetIncrementalState.accumulatedItems.push(...items);
308
+ sheetIncrementalState.accumulatedItems.sort(
309
+ (a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0),
310
+ );
311
+ if (sheetIncrementalState.accumulatedItems.length > HISTORY_MERGE_CAP) {
312
+ sheetIncrementalState.accumulatedItems =
313
+ sheetIncrementalState.accumulatedItems.slice(0, HISTORY_MERGE_CAP);
314
+ }
315
+ } catch (e) {
316
+ const msg = /** @type {Error} */ (e).message || String(e);
317
+ lastHistorySheetMeta.sheetHint = `Google Sheet error: ${msg.slice(0, 140)}`;
318
+ console.warn('[CopyHub overlay]', lastHistorySheetMeta.sheetHint);
319
+ sheetIncrementalState.exhausted = true;
320
+ return;
321
+ }
322
+ sheetIncrementalState.nextDaysAgo += 1;
323
+ }
324
+
325
+ /**
326
+ * Ensure merged history covers at least `page * pageSize` items (capped), fetching extra Sheet tabs only if needed.
327
+ */
328
+ async function ensureMergedHistoryCoversPage(page, pageSize) {
329
+ const localItems = buildLocalHistoryItems();
330
+ const sheetTarget = await loadSheetSyncTarget();
331
+ const tok = await loadTokens();
332
+ const sheetOk =
333
+ Boolean(sheetTarget) && Boolean(tok?.refresh_token || tok?.access_token);
334
+
335
+ if (!sheetOk) {
336
+ if (!sheetTarget) {
337
+ lastHistorySheetMeta = {
338
+ sheetFetched: 0,
339
+ sheetHint: 'Google Sheet: not configured — run copyhub login',
340
+ };
341
+ } else {
342
+ lastHistorySheetMeta = {
343
+ sheetFetched: 0,
344
+ sheetHint: 'Google Sheet: not signed in — run copyhub login',
345
+ };
346
+ }
347
+ sheetIncrementalState.exhausted = true;
348
+ historyMergedCache.merged = mergeHistoryForOverlay(localItems, [], HISTORY_MERGE_CAP);
349
+ return;
350
+ }
351
+
352
+ const targetMin = Math.min(page * pageSize, HISTORY_MERGE_CAP);
353
+
354
+ while (true) {
355
+ const merged = mergeHistoryForOverlay(
356
+ localItems,
357
+ sheetIncrementalState.accumulatedItems,
358
+ HISTORY_MERGE_CAP,
359
+ );
360
+ historyMergedCache.merged = merged;
361
+
362
+ if (merged.length >= HISTORY_MERGE_CAP) break;
363
+ if (sheetIncrementalState.exhausted) break;
364
+ /** Merge Sheet at least once when configured so dedupe / synced flags match Sheet. */
365
+ if (merged.length >= targetMin && sheetIncrementalState.nextDaysAgo > 0) break;
366
+
367
+ await fetchNextDailyTabIntoState();
368
+ }
369
+
370
+ const preservedErr =
371
+ typeof lastHistorySheetMeta.sheetHint === 'string' &&
372
+ lastHistorySheetMeta.sheetHint.startsWith('Google Sheet error:');
373
+
374
+ lastHistorySheetMeta.sheetFetched = sheetIncrementalState.accumulatedItems.length;
375
+
376
+ if (!preservedErr) {
377
+ if (!sheetIncrementalState.exhausted) {
378
+ lastHistorySheetMeta.sheetHint = `Google Sheet: ${sheetIncrementalState.accumulatedItems.length} rows · more when you page`;
379
+ } else if (sheetIncrementalState.accumulatedItems.length === 0) {
380
+ lastHistorySheetMeta.sheetHint =
381
+ 'Google Sheet: 0 rows in last 31 days (check COPYHUB-YYYY-MM-DD tabs / timezone)';
382
+ } else {
383
+ lastHistorySheetMeta.sheetHint = `Google Sheet: ${sheetIncrementalState.accumulatedItems.length} rows loaded`;
384
+ }
385
+ }
386
+ }
387
+
388
+ function buildLocalHistoryItems() {
389
+ return readRecentHistorySync(HISTORY_LOCAL_LINES).map((row) => ({
390
+ ts: row.ts || '',
391
+ text: typeof row.text === 'string' ? row.text : '',
392
+ synced: Boolean(row.syncedToSheet || row.syncedToGmail),
393
+ }));
394
+ }
395
+
396
+ /** @param {ReturnType<typeof buildLocalHistoryItems>} items */
397
+ function paginateHistoryItems(items, page, pageSize) {
398
+ const total = items.length;
399
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
400
+ const safePage = Math.min(Math.max(page, 1), totalPages);
401
+ const start = (safePage - 1) * pageSize;
402
+ return {
403
+ items: items.slice(start, start + pageSize),
404
+ page: safePage,
405
+ pageSize,
406
+ total,
407
+ totalPages,
408
+ };
409
+ }
410
+
225
411
  function registerIpc() {
226
412
  ipcMain.handle('overlay:meta', () => ({
227
413
  ...overlayHotkeyMeta,
@@ -230,17 +416,81 @@ function registerIpc() {
230
416
  sticky: STICKY_NO_BLUR,
231
417
  }));
232
418
 
233
- ipcMain.handle('history:get', () => {
419
+ /** Fast path: local history.jsonl only (overlay shows this while Sheet loads). */
420
+ ipcMain.handle('history:getLocal', (_e, opts = {}) => {
421
+ try {
422
+ const pageSize = Math.min(Math.max(Number(opts.pageSize) || 10, 1), 50);
423
+ let page = Math.max(Number(opts.page) || 1, 1);
424
+ const localItems = buildLocalHistoryItems();
425
+ const paginated = paginateHistoryItems(localItems, page, pageSize);
426
+ return {
427
+ ...paginated,
428
+ provisional: true,
429
+ sheetHint:
430
+ localItems.length > 0
431
+ ? 'Showing local copies · loading Google Sheet…'
432
+ : 'Loading Google Sheet…',
433
+ sheetFetched: 0,
434
+ sheetHasMore: false,
435
+ };
436
+ } catch (e) {
437
+ return {
438
+ error: /** @type {Error} */ (e).message,
439
+ items: [],
440
+ page: 1,
441
+ pageSize: 10,
442
+ total: 0,
443
+ totalPages: 1,
444
+ provisional: true,
445
+ sheetHint: '',
446
+ sheetFetched: 0,
447
+ sheetHasMore: false,
448
+ };
449
+ }
450
+ });
451
+
452
+ ipcMain.handle('history:get', async (_e, opts = {}) => {
234
453
  try {
454
+ const pageSize = Math.min(Math.max(Number(opts.pageSize) || 10, 1), 50);
455
+ let page = Math.max(Number(opts.page) || 1, 1);
456
+ const refresh = Boolean(opts.refresh);
457
+
458
+ if (refresh) {
459
+ resetSheetIncrementalState();
460
+ lastHistorySheetMeta = { sheetFetched: 0, sheetHint: '' };
461
+ historyMergedCache.merged = [];
462
+ }
463
+
464
+ await ensureMergedHistoryCoversPage(page, pageSize);
465
+
466
+ const total = historyMergedCache.merged.length;
467
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
468
+ page = Math.min(page, totalPages);
469
+ const start = (page - 1) * pageSize;
470
+ const items = historyMergedCache.merged.slice(start, start + pageSize);
471
+
235
472
  return {
236
- items: readRecentHistorySync(200).map((row) => ({
237
- ts: row.ts || '',
238
- text: typeof row.text === 'string' ? row.text : '',
239
- synced: Boolean(row.syncedToSheet || row.syncedToGmail),
240
- })),
473
+ items,
474
+ page,
475
+ pageSize,
476
+ total,
477
+ totalPages,
478
+ sheetHint: lastHistorySheetMeta.sheetHint,
479
+ sheetFetched: lastHistorySheetMeta.sheetFetched,
480
+ sheetHasMore: !sheetIncrementalState.exhausted,
241
481
  };
242
482
  } catch (e) {
243
- return { error: /** @type {Error} */ (e).message, items: [] };
483
+ return {
484
+ error: /** @type {Error} */ (e).message,
485
+ items: [],
486
+ page: 1,
487
+ pageSize: 10,
488
+ total: 0,
489
+ totalPages: 1,
490
+ sheetHint: '',
491
+ sheetFetched: 0,
492
+ sheetHasMore: false,
493
+ };
244
494
  }
245
495
  });
246
496
 
package/ui/preload.cjs CHANGED
@@ -2,7 +2,10 @@ const { contextBridge, ipcRenderer } = require('electron');
2
2
 
3
3
  contextBridge.exposeInMainWorld('copyhub', {
4
4
  getMeta: () => ipcRenderer.invoke('overlay:meta'),
5
- getHistory: () => ipcRenderer.invoke('history:get'),
5
+ /** @param {{ page?: number, pageSize?: number, refresh?: boolean }} [opts] */
6
+ getHistory: (opts) => ipcRenderer.invoke('history:get', opts ?? {}),
7
+ /** Local history only — instant; used while Sheet sync runs. */
8
+ getHistoryLocal: (opts) => ipcRenderer.invoke('history:getLocal', opts ?? {}),
6
9
  copyPick: (text) => ipcRenderer.invoke('history:copy', text),
7
10
  onOpen: (fn) => {
8
11
  ipcRenderer.on('overlay:open', (_e) => {
@@ -58,6 +58,53 @@
58
58
  cursor: grabbing;
59
59
  }
60
60
 
61
+ #list-wrap {
62
+ flex: 1;
63
+ min-height: 0;
64
+ display: flex;
65
+ flex-direction: column;
66
+ position: relative;
67
+ }
68
+
69
+ #list-loading {
70
+ flex-shrink: 0;
71
+ display: none;
72
+ align-items: center;
73
+ gap: 10px;
74
+ padding: 8px 14px;
75
+ font-size: 12px;
76
+ font-weight: 600;
77
+ color: var(--accent);
78
+ background: linear-gradient(90deg, var(--accent-soft), rgba(61, 90, 254, 0.02));
79
+ border-bottom: 1px solid var(--border);
80
+ -webkit-app-region: no-drag;
81
+ app-region: no-drag;
82
+ animation: hint-pulse 1.4s ease-in-out infinite;
83
+ }
84
+
85
+ #list-loading.visible {
86
+ display: flex;
87
+ }
88
+
89
+ #list-loading .spinner {
90
+ width: 16px;
91
+ height: 16px;
92
+ flex-shrink: 0;
93
+ border: 2px solid rgba(61, 90, 254, 0.35);
94
+ border-top-color: var(--accent);
95
+ border-radius: 50%;
96
+ animation: spin 0.65s linear infinite;
97
+ }
98
+
99
+ @keyframes spin {
100
+ to { transform: rotate(360deg); }
101
+ }
102
+
103
+ @keyframes hint-pulse {
104
+ 0%, 100% { opacity: 1; }
105
+ 50% { opacity: 0.82; }
106
+ }
107
+
61
108
  #list {
62
109
  flex: 1;
63
110
  min-height: 0;
@@ -173,33 +220,173 @@
173
220
  border-radius: var(--radius);
174
221
  font-size: 13px;
175
222
  }
223
+
224
+ #pager {
225
+ flex-shrink: 0;
226
+ display: none;
227
+ align-items: center;
228
+ justify-content: center;
229
+ gap: 12px;
230
+ padding: 8px 12px 12px;
231
+ border-top: 1px solid var(--border);
232
+ background: linear-gradient(180deg, var(--bg) 0%, #fafbfc 100%);
233
+ -webkit-app-region: no-drag;
234
+ app-region: no-drag;
235
+ }
236
+
237
+ #pager.visible {
238
+ display: flex;
239
+ }
240
+
241
+ #pager button {
242
+ font: inherit;
243
+ font-size: 13px;
244
+ font-weight: 600;
245
+ padding: 6px 14px;
246
+ border-radius: 8px;
247
+ border: 1px solid var(--border);
248
+ background: var(--surface);
249
+ color: var(--text);
250
+ cursor: pointer;
251
+ transition: background 0.15s, border-color 0.15s;
252
+ }
253
+
254
+ #pager button:hover:not(:disabled) {
255
+ background: var(--surface-hover);
256
+ border-color: rgba(61, 90, 254, 0.35);
257
+ }
258
+
259
+ #pager button:disabled {
260
+ opacity: 0.45;
261
+ cursor: default;
262
+ }
263
+
264
+ #pg-info {
265
+ font-size: 12px;
266
+ color: var(--text-muted);
267
+ min-width: 0;
268
+ text-align: center;
269
+ flex: 1;
270
+ }
271
+
272
+ .sheet-hint {
273
+ flex-shrink: 0;
274
+ font-size: 11px;
275
+ line-height: 1.45;
276
+ color: var(--text-muted);
277
+ padding: 6px 14px 4px;
278
+ text-align: center;
279
+ border-top: 1px solid var(--border);
280
+ background: var(--bg);
281
+ -webkit-app-region: no-drag;
282
+ app-region: no-drag;
283
+ }
284
+
285
+ .sheet-hint[hidden] {
286
+ display: none !important;
287
+ }
176
288
  </style>
177
289
  </head>
178
290
  <body>
179
291
  <div id="drag-strip" title="Drag to move window"></div>
180
- <div id="list"></div>
292
+ <div id="list-wrap">
293
+ <div id="list-loading" aria-live="polite">
294
+ <div class="spinner" aria-hidden="true"></div>
295
+ <span>Syncing Google Sheet…</span>
296
+ </div>
297
+ <div id="list"></div>
298
+ </div>
299
+ <div id="sheet-hint" class="sheet-hint" hidden></div>
300
+ <div id="pager" aria-label="Pagination">
301
+ <button type="button" id="pg-prev">Previous</button>
302
+ <span id="pg-info"></span>
303
+ <button type="button" id="pg-next">Next</button>
304
+ </div>
181
305
  <script>
182
306
  const listEl = document.getElementById('list');
307
+ const listLoadingEl = document.getElementById('list-loading');
308
+ const pagerEl = document.getElementById('pager');
309
+ const pgPrev = document.getElementById('pg-prev');
310
+ const pgNext = document.getElementById('pg-next');
311
+ const pgInfo = document.getElementById('pg-info');
312
+ const sheetHintEl = document.getElementById('sheet-hint');
313
+
314
+ const pageSize = 10;
315
+ let currentPage = 1;
316
+ /** @type {{ total: number, totalPages: number }} */
317
+ let lastPaging = { total: 0, totalPages: 1 };
318
+ let lastSheetHasMore = false;
319
+ let sheetSyncInFlight = false;
183
320
 
184
- async function refresh() {
321
+ function setLoadingUi(on) {
322
+ listLoadingEl.classList.toggle('visible', on);
323
+ }
324
+
325
+ function syncPagerBar() {
326
+ const { total, totalPages } = lastPaging;
327
+ if (!total) return;
328
+ pgPrev.disabled = sheetSyncInFlight || currentPage <= 1;
329
+ pgNext.disabled =
330
+ sheetSyncInFlight ||
331
+ (currentPage * pageSize >= total && !lastSheetHasMore);
332
+ const from = (currentPage - 1) * pageSize + 1;
333
+ const to = Math.min(currentPage * pageSize, total);
334
+ const moreMark = lastSheetHasMore && currentPage * pageSize >= total ? '+' : '';
335
+ const pagesShown =
336
+ lastSheetHasMore && currentPage * pageSize >= total
337
+ ? Math.max(totalPages, currentPage + 1)
338
+ : totalPages;
339
+ pgInfo.textContent = `Page ${currentPage} / ${pagesShown} · ${from}–${to} of ${total}${moreMark}`;
340
+ }
341
+
342
+ function renderHistoryResponse(res) {
185
343
  listEl.innerHTML = '';
186
- const res = await window.copyhub.getHistory();
344
+
187
345
  if (res && res.error) {
346
+ pagerEl.classList.remove('visible');
347
+ sheetHintEl.hidden = true;
188
348
  const e = document.createElement('div');
189
349
  e.id = 'err';
190
350
  e.textContent = res.error;
191
351
  listEl.appendChild(e);
352
+ lastPaging = { total: 0, totalPages: 1 };
353
+ lastSheetHasMore = false;
192
354
  return;
193
355
  }
356
+
357
+ lastSheetHasMore = Boolean(res.sheetHasMore);
358
+ currentPage = res.page ?? currentPage;
194
359
  const rows = res && Array.isArray(res.items) ? res.items : [];
195
- if (!rows.length) {
360
+ const total = res.total ?? 0;
361
+ const totalPages = res.totalPages ?? 1;
362
+ lastPaging = { total, totalPages };
363
+
364
+ if (!total) {
365
+ pagerEl.classList.remove('visible');
366
+ if (res.sheetHint) {
367
+ sheetHintEl.hidden = false;
368
+ sheetHintEl.textContent = res.sheetHint;
369
+ } else {
370
+ sheetHintEl.hidden = true;
371
+ }
196
372
  const d = document.createElement('div');
197
373
  d.id = 'empty';
198
374
  d.textContent =
199
- 'No history yet — run copyhub start and copy something. Click a row to copy again; click outside or Esc to close.';
375
+ 'No local history yet — run copyhub start and copy something. Click a row to copy again; click outside or Esc to close.';
200
376
  listEl.appendChild(d);
201
377
  return;
202
378
  }
379
+
380
+ if (res.sheetHint) {
381
+ sheetHintEl.hidden = false;
382
+ sheetHintEl.textContent = res.sheetHint;
383
+ } else {
384
+ sheetHintEl.hidden = true;
385
+ }
386
+
387
+ pagerEl.classList.add('visible');
388
+ syncPagerBar();
389
+
203
390
  for (const r of rows) {
204
391
  const div = document.createElement('div');
205
392
  div.className = 'row' + (r.synced ? ' synced' : '');
@@ -215,10 +402,72 @@
215
402
  }
216
403
  }
217
404
 
405
+ async function loadHistory(opt = {}) {
406
+ const refreshCache = Boolean(opt.refreshCache);
407
+ if (refreshCache) currentPage = 1;
408
+ if (typeof opt.page === 'number' && opt.page >= 1) {
409
+ currentPage = opt.page;
410
+ }
411
+
412
+ if (refreshCache) {
413
+ sheetSyncInFlight = true;
414
+ setLoadingUi(true);
415
+ pgPrev.disabled = true;
416
+ pgNext.disabled = true;
417
+
418
+ const quick = await window.copyhub.getHistoryLocal({
419
+ page: currentPage,
420
+ pageSize,
421
+ });
422
+
423
+ if (quick.error) {
424
+ sheetSyncInFlight = false;
425
+ setLoadingUi(false);
426
+ renderHistoryResponse(quick);
427
+ return;
428
+ }
429
+
430
+ currentPage = quick.page ?? currentPage;
431
+ renderHistoryResponse(quick);
432
+
433
+ try {
434
+ const full = await window.copyhub.getHistory({
435
+ page: currentPage,
436
+ pageSize,
437
+ refresh: true,
438
+ });
439
+ currentPage = full.page ?? currentPage;
440
+ renderHistoryResponse(full);
441
+ } finally {
442
+ sheetSyncInFlight = false;
443
+ setLoadingUi(false);
444
+ syncPagerBar();
445
+ }
446
+ return;
447
+ }
448
+
449
+ const res = await window.copyhub.getHistory({
450
+ page: currentPage,
451
+ pageSize,
452
+ refresh: false,
453
+ });
454
+ renderHistoryResponse(res);
455
+ }
456
+
457
+ pgPrev.addEventListener('click', () => {
458
+ if (sheetSyncInFlight || currentPage <= 1) return;
459
+ void loadHistory({ page: currentPage - 1 });
460
+ });
461
+
462
+ pgNext.addEventListener('click', () => {
463
+ if (sheetSyncInFlight) return;
464
+ void loadHistory({ page: currentPage + 1 });
465
+ });
466
+
218
467
  window.copyhub.onOpen(() => {
219
- void refresh();
468
+ void loadHistory({ refreshCache: true });
220
469
  });
221
- void refresh();
222
470
  </script>
223
471
  </body>
224
472
  </html>
473
+