opencode-worktree 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worktree",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "TUI for managing git worktrees with opencode integration.",
@@ -42,13 +42,11 @@
42
42
  "node": ">=18"
43
43
  },
44
44
  "dependencies": {
45
- "@opentui/core": "^0.1.75",
46
- "update-notifier": "^7.3.1"
45
+ "@opentui/core": "^0.1.75"
47
46
  },
48
47
  "devDependencies": {
49
48
  "@types/bun": "^1.2.0",
50
49
  "@types/node": "^24.2.0",
51
- "@types/update-notifier": "^6.0.8",
52
50
  "typescript": "^5.9.3"
53
51
  }
54
52
  }
package/src/ui.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  type KeyEvent,
11
11
  type SelectOption,
12
12
  } from "@opentui/core";
13
- import updateNotifier from "update-notifier";
13
+ import { checkForUpdate } from "./update-check.js";
14
14
  import { basename } from "node:path";
15
15
  import {
16
16
  createWorktree,
@@ -128,17 +128,17 @@ class WorktreeSelector {
128
128
 
129
129
  // Display version or update notification in title line
130
130
  if (this.pkg) {
131
- const notifier = updateNotifier({ pkg: this.pkg });
131
+ const updateInfo = checkForUpdate(this.pkg);
132
132
 
133
133
  let noticeContent: string;
134
134
  let noticeColor: string;
135
135
 
136
- if (notifier.update) {
136
+ if (updateInfo?.hasUpdate) {
137
137
  // Update available
138
- noticeContent = `Update: ${notifier.update.current} → ${notifier.update.latest} (npm i -g)`;
138
+ noticeContent = `Update: ${updateInfo.current} → ${updateInfo.latest} (npm i -g)`;
139
139
  noticeColor = "#F59E0B"; // Amber
140
140
  } else {
141
- // On latest version
141
+ // On latest version (or no cache yet)
142
142
  noticeContent = `v${this.pkg.version}`;
143
143
  noticeColor = "#64748B"; // Subtle gray
144
144
  }
@@ -1063,7 +1063,7 @@ class WorktreeSelector {
1063
1063
  const branchDisplay = worktree.branch || basename(worktree.path);
1064
1064
 
1065
1065
  // Build dialog title
1066
- const title = "DELETE WORKTREE";
1066
+ const title = `Remove: ${branchDisplay}`;
1067
1067
 
1068
1068
  this.confirmContainer = new BoxRenderable(this.renderer, {
1069
1069
  id: "confirm-container",
@@ -1071,30 +1071,18 @@ class WorktreeSelector {
1071
1071
  left: 2,
1072
1072
  top: 3,
1073
1073
  width: 76,
1074
- height: isDirty ? 12 : 10,
1074
+ height: isDirty ? 10 : 8,
1075
1075
  borderStyle: "single",
1076
- borderColor: "#EF4444",
1076
+ borderColor: "#F59E0B",
1077
1077
  title,
1078
1078
  titleAlignment: "center",
1079
- backgroundColor: "#1C1917",
1079
+ backgroundColor: "#0F172A",
1080
1080
  border: true,
1081
1081
  });
1082
1082
  this.renderer.root.add(this.confirmContainer);
1083
1083
 
1084
- // Branch name prominently displayed
1085
- let yOffset = 1;
1086
- const branchHeader = new TextRenderable(this.renderer, {
1087
- id: "confirm-branch",
1088
- position: "absolute",
1089
- left: 1,
1090
- top: yOffset,
1091
- content: `Branch: ${branchDisplay}`,
1092
- fg: "#FBBF24",
1093
- });
1094
- this.confirmContainer.add(branchHeader);
1095
- yOffset += 2;
1096
-
1097
1084
  // Warning for dirty worktree
1085
+ let yOffset = 1;
1098
1086
  if (isDirty) {
1099
1087
  const warningText = new TextRenderable(this.renderer, {
1100
1088
  id: "confirm-warning",
@@ -1102,7 +1090,7 @@ class WorktreeSelector {
1102
1090
  left: 1,
1103
1091
  top: yOffset,
1104
1092
  content: "⚠ This worktree has uncommitted changes!",
1105
- fg: "#EF4444",
1093
+ fg: "#F59E0B",
1106
1094
  });
1107
1095
  this.confirmContainer.add(warningText);
1108
1096
  yOffset += 2;
@@ -1114,7 +1102,7 @@ class WorktreeSelector {
1114
1102
  left: 1,
1115
1103
  top: yOffset,
1116
1104
  content: `Path: ${worktree.path}`,
1117
- fg: "#A8A29E",
1105
+ fg: "#94A3B8",
1118
1106
  });
1119
1107
  this.confirmContainer.add(pathText);
1120
1108
  yOffset += 2;
@@ -1146,13 +1134,13 @@ class WorktreeSelector {
1146
1134
  width: 72,
1147
1135
  height: 4,
1148
1136
  options,
1149
- backgroundColor: "#1C1917",
1150
- focusedBackgroundColor: "#292524",
1151
- selectedBackgroundColor: "#44403C",
1152
- textColor: "#E7E5E4",
1153
- selectedTextColor: "#F87171",
1154
- descriptionColor: "#A8A29E",
1155
- selectedDescriptionColor: "#E7E5E4",
1137
+ backgroundColor: "#0F172A",
1138
+ focusedBackgroundColor: "#1E293B",
1139
+ selectedBackgroundColor: "#1E3A5F",
1140
+ textColor: "#E2E8F0",
1141
+ selectedTextColor: "#38BDF8",
1142
+ descriptionColor: "#94A3B8",
1143
+ selectedDescriptionColor: "#E2E8F0",
1156
1144
  showDescription: true,
1157
1145
  wrapSelection: true,
1158
1146
  });
@@ -1192,6 +1180,17 @@ class WorktreeSelector {
1192
1180
  this.confirmSelect = null;
1193
1181
  }
1194
1182
 
1183
+ // Restore original title and colors (in case we came from delete mode)
1184
+ this.title.content = "OPENCODE WORKTREES";
1185
+ this.title.fg = "#E2E8F0";
1186
+ this.selectElement.backgroundColor = "#0F172A";
1187
+ this.selectElement.focusedBackgroundColor = "#1E293B";
1188
+ this.selectElement.selectedBackgroundColor = "#1E3A5F";
1189
+ this.selectElement.textColor = "#E2E8F0";
1190
+ this.selectElement.selectedTextColor = "#38BDF8";
1191
+ this.selectElement.descriptionColor = "#94A3B8";
1192
+ this.selectElement.selectedDescriptionColor = "#E2E8F0";
1193
+
1195
1194
  this.selectElement.visible = true;
1196
1195
  this.instructions.content =
1197
1196
  "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
@@ -1283,8 +1282,21 @@ class WorktreeSelector {
1283
1282
  this.isSelectingForDelete = true;
1284
1283
  this.selectedForDelete.clear();
1285
1284
 
1286
- // Rebuild options to show checkboxes
1287
- this.selectElement.options = this.buildOptions(worktrees);
1285
+ // Change title to indicate delete mode
1286
+ this.title.content = "DELETE WORKTREES";
1287
+ this.title.fg = "#EF4444"; // Red
1288
+
1289
+ // Change select element colors to danger theme
1290
+ this.selectElement.backgroundColor = "#1C1917";
1291
+ this.selectElement.focusedBackgroundColor = "#292524";
1292
+ this.selectElement.selectedBackgroundColor = "#44403C";
1293
+ this.selectElement.textColor = "#E7E5E4";
1294
+ this.selectElement.selectedTextColor = "#F87171";
1295
+ this.selectElement.descriptionColor = "#A8A29E";
1296
+ this.selectElement.selectedDescriptionColor = "#E7E5E4";
1297
+
1298
+ // Rebuild options to show checkboxes (only deletable worktrees)
1299
+ this.selectElement.options = this.buildOptions(deletableWorktrees);
1288
1300
  this.instructions.content =
1289
1301
  "Enter toggle selection • d confirm delete • Esc cancel";
1290
1302
  this.setStatus("Select worktrees to delete, then press 'd' to confirm.", "info");
@@ -1294,6 +1306,20 @@ class WorktreeSelector {
1294
1306
  private exitSelectMode(): void {
1295
1307
  this.isSelectingForDelete = false;
1296
1308
  this.selectedForDelete.clear();
1309
+
1310
+ // Restore original title
1311
+ this.title.content = "OPENCODE WORKTREES";
1312
+ this.title.fg = "#E2E8F0";
1313
+
1314
+ // Restore original select element colors
1315
+ this.selectElement.backgroundColor = "#0F172A";
1316
+ this.selectElement.focusedBackgroundColor = "#1E293B";
1317
+ this.selectElement.selectedBackgroundColor = "#1E3A5F";
1318
+ this.selectElement.textColor = "#E2E8F0";
1319
+ this.selectElement.selectedTextColor = "#38BDF8";
1320
+ this.selectElement.descriptionColor = "#94A3B8";
1321
+ this.selectElement.selectedDescriptionColor = "#E2E8F0";
1322
+
1297
1323
  this.loadWorktrees();
1298
1324
  this.instructions.content =
1299
1325
  "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
@@ -1320,10 +1346,13 @@ class WorktreeSelector {
1320
1346
  this.selectedForDelete.add(worktree.path);
1321
1347
  }
1322
1348
 
1323
- // Rebuild options to update checkboxes
1349
+ // Rebuild options to update checkboxes (only deletable worktrees)
1324
1350
  if (this.repoRoot) {
1325
1351
  const worktrees = listWorktrees(this.repoRoot);
1326
- this.selectElement.options = this.buildOptions(worktrees);
1352
+ const deletableWorktrees = worktrees.filter(
1353
+ (wt) => !isMainWorktree(this.repoRoot!, wt.path)
1354
+ );
1355
+ this.selectElement.options = this.buildOptions(deletableWorktrees);
1327
1356
  // Restore selection index
1328
1357
  this.selectElement.setSelectedIndex(selectedIndex);
1329
1358
  }
@@ -1369,7 +1398,7 @@ class WorktreeSelector {
1369
1398
  const hasDirty = dirtyWorktrees.length > 0;
1370
1399
 
1371
1400
  const count = worktrees.length;
1372
- const title = `DELETE ${count} WORKTREE${count === 1 ? "" : "S"}`;
1401
+ const title = `Delete ${count} worktree${count === 1 ? "" : "s"}`;
1373
1402
 
1374
1403
  this.confirmContainer = new BoxRenderable(this.renderer, {
1375
1404
  id: "confirm-container",
@@ -1377,36 +1406,18 @@ class WorktreeSelector {
1377
1406
  left: 2,
1378
1407
  top: 3,
1379
1408
  width: 76,
1380
- height: hasDirty ? 14 : 12,
1409
+ height: hasDirty ? 12 : 10,
1381
1410
  borderStyle: "single",
1382
- borderColor: "#EF4444",
1411
+ borderColor: "#F59E0B",
1383
1412
  title,
1384
1413
  titleAlignment: "center",
1385
- backgroundColor: "#1C1917",
1414
+ backgroundColor: "#0F172A",
1386
1415
  border: true,
1387
1416
  });
1388
1417
  this.renderer.root.add(this.confirmContainer);
1389
1418
 
1390
1419
  let yOffset = 1;
1391
1420
 
1392
- // List branches to be deleted prominently
1393
- const branchNames = worktrees
1394
- .map((wt) => wt.branch || basename(wt.path))
1395
- .slice(0, 3);
1396
- const displayList =
1397
- branchNames.join(", ") + (worktrees.length > 3 ? `, +${worktrees.length - 3} more` : "");
1398
-
1399
- const branchHeader = new TextRenderable(this.renderer, {
1400
- id: "confirm-branches",
1401
- position: "absolute",
1402
- left: 1,
1403
- top: yOffset,
1404
- content: `Branches: ${displayList}`,
1405
- fg: "#FBBF24",
1406
- });
1407
- this.confirmContainer.add(branchHeader);
1408
- yOffset += 2;
1409
-
1410
1421
  // Warning for dirty worktrees
1411
1422
  if (hasDirty) {
1412
1423
  const warningText = new TextRenderable(this.renderer, {
@@ -1415,22 +1426,28 @@ class WorktreeSelector {
1415
1426
  left: 1,
1416
1427
  top: yOffset,
1417
1428
  content: `⚠ ${dirtyWorktrees.length} worktree${dirtyWorktrees.length === 1 ? " has" : "s have"} uncommitted changes!`,
1418
- fg: "#EF4444",
1429
+ fg: "#F59E0B",
1419
1430
  });
1420
1431
  this.confirmContainer.add(warningText);
1421
1432
  yOffset += 2;
1422
1433
  }
1423
1434
 
1424
- // Info text
1425
- const infoText = new TextRenderable(this.renderer, {
1426
- id: "confirm-info",
1435
+ // List worktrees to be deleted
1436
+ const branchNames = worktrees
1437
+ .map((wt) => wt.branch || basename(wt.path))
1438
+ .slice(0, 3);
1439
+ const displayList =
1440
+ branchNames.join(", ") + (worktrees.length > 3 ? `, +${worktrees.length - 3} more` : "");
1441
+
1442
+ const listText = new TextRenderable(this.renderer, {
1443
+ id: "confirm-list",
1427
1444
  position: "absolute",
1428
1445
  left: 1,
1429
1446
  top: yOffset,
1430
- content: "This will remove worktree directories from disk.",
1431
- fg: "#A8A29E",
1447
+ content: `Worktrees: ${displayList}`,
1448
+ fg: "#94A3B8",
1432
1449
  });
1433
- this.confirmContainer.add(infoText);
1450
+ this.confirmContainer.add(listText);
1434
1451
  yOffset += 2;
1435
1452
 
1436
1453
  // Build options
@@ -1460,13 +1477,13 @@ class WorktreeSelector {
1460
1477
  width: 72,
1461
1478
  height: 4,
1462
1479
  options,
1463
- backgroundColor: "#1C1917",
1464
- focusedBackgroundColor: "#292524",
1465
- selectedBackgroundColor: "#44403C",
1466
- textColor: "#E7E5E4",
1467
- selectedTextColor: "#F87171",
1468
- descriptionColor: "#A8A29E",
1469
- selectedDescriptionColor: "#E7E5E4",
1480
+ backgroundColor: "#0F172A",
1481
+ focusedBackgroundColor: "#1E293B",
1482
+ selectedBackgroundColor: "#1E3A5F",
1483
+ textColor: "#E2E8F0",
1484
+ selectedTextColor: "#38BDF8",
1485
+ descriptionColor: "#94A3B8",
1486
+ selectedDescriptionColor: "#E2E8F0",
1470
1487
  showDescription: true,
1471
1488
  wrapSelection: true,
1472
1489
  });
@@ -0,0 +1,163 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+
6
+ export type UpdateInfo = {
7
+ hasUpdate: boolean;
8
+ current: string;
9
+ latest: string;
10
+ };
11
+
12
+ type UpdateCache = {
13
+ name: string; // Package name to verify cache matches
14
+ latest: string;
15
+ lastCheck: number;
16
+ };
17
+
18
+ const CACHE_DIR = path.join(os.homedir(), ".config", "configstore");
19
+ const CACHE_FILE = path.join(CACHE_DIR, "opencode-worktree-update.json");
20
+
21
+ // Check interval: 1 hour in milliseconds
22
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000;
23
+
24
+ /**
25
+ * Read the cached update info from disk
26
+ * Returns null if cache doesn't exist or package name doesn't match
27
+ */
28
+ function readCache(packageName: string): UpdateCache | null {
29
+ try {
30
+ if (!fs.existsSync(CACHE_FILE)) {
31
+ return null;
32
+ }
33
+ const content = fs.readFileSync(CACHE_FILE, "utf-8");
34
+ const cache = JSON.parse(content) as UpdateCache;
35
+
36
+ // Verify the cache is for the correct package
37
+ if (cache.name !== packageName) {
38
+ return null;
39
+ }
40
+
41
+ return cache;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Compare two semver version strings
49
+ * Returns true if latest > current
50
+ */
51
+ function isNewerVersion(current: string, latest: string): boolean {
52
+ // Handle dev versions
53
+ if (current === "dev" || current === "0.0.0") {
54
+ return false;
55
+ }
56
+
57
+ const parseVersion = (v: string): number[] => {
58
+ return v
59
+ .replace(/^v/, "")
60
+ .split(".")
61
+ .map((n) => parseInt(n, 10) || 0);
62
+ };
63
+
64
+ const currentParts = parseVersion(current);
65
+ const latestParts = parseVersion(latest);
66
+
67
+ for (let i = 0; i < 3; i++) {
68
+ const c = currentParts[i] || 0;
69
+ const l = latestParts[i] || 0;
70
+ if (l > c) return true;
71
+ if (l < c) return false;
72
+ }
73
+
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Spawn a detached background process to fetch the latest version from npm
79
+ * and write it to the cache file. This doesn't block the main process.
80
+ */
81
+ function fetchLatestInBackground(packageName: string): void {
82
+ // Create an inline script that fetches from npm and writes to cache
83
+ const script = `
84
+ const https = require('https');
85
+ const fs = require('fs');
86
+ const path = require('path');
87
+ const os = require('os');
88
+
89
+ const cacheDir = path.join(os.homedir(), '.config', 'configstore');
90
+ const cacheFile = path.join(cacheDir, 'opencode-worktree-update.json');
91
+
92
+ const url = 'https://registry.npmjs.org/${packageName}/latest';
93
+
94
+ https.get(url, { headers: { 'Accept': 'application/json' } }, (res) => {
95
+ let data = '';
96
+ res.on('data', (chunk) => { data += chunk; });
97
+ res.on('end', () => {
98
+ try {
99
+ const pkg = JSON.parse(data);
100
+ if (pkg.version) {
101
+ if (!fs.existsSync(cacheDir)) {
102
+ fs.mkdirSync(cacheDir, { recursive: true });
103
+ }
104
+ fs.writeFileSync(cacheFile, JSON.stringify({
105
+ name: '${packageName}',
106
+ latest: pkg.version,
107
+ lastCheck: Date.now()
108
+ }, null, 2));
109
+ }
110
+ } catch {}
111
+ });
112
+ }).on('error', () => {});
113
+ `;
114
+
115
+ try {
116
+ // Spawn node to run the script
117
+ const child = spawn(process.execPath, ["-e", script], {
118
+ detached: true,
119
+ stdio: "ignore",
120
+ env: { ...process.env },
121
+ });
122
+
123
+ // Unref so the parent process can exit independently
124
+ child.unref();
125
+ } catch {
126
+ // Silently fail - background fetch is best-effort
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Check for updates. This is non-blocking:
132
+ * 1. Reads the cached latest version (if available)
133
+ * 2. Compares against current version
134
+ * 3. Spawns a background process to refresh the cache for next time
135
+ *
136
+ * Returns null if no cache exists yet (first run)
137
+ */
138
+ export function checkForUpdate(pkg: {
139
+ name: string;
140
+ version: string;
141
+ }): UpdateInfo | null {
142
+ const cache = readCache(pkg.name);
143
+ const now = Date.now();
144
+
145
+ // Spawn background fetch if cache is stale or doesn't exist
146
+ const shouldFetch =
147
+ !cache || now - cache.lastCheck > CHECK_INTERVAL_MS;
148
+
149
+ if (shouldFetch) {
150
+ fetchLatestInBackground(pkg.name);
151
+ }
152
+
153
+ // If no cache, we can't determine if there's an update yet
154
+ if (!cache) {
155
+ return null;
156
+ }
157
+
158
+ return {
159
+ hasUpdate: isNewerVersion(pkg.version, cache.latest),
160
+ current: pkg.version,
161
+ latest: cache.latest,
162
+ };
163
+ }