@homescriptone/opencode-viewer-extender 1.3.0 → 1.5.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 CHANGED
@@ -11,7 +11,7 @@ Reads and writes `~/.local/share/opencode/opencode.db`. Everything stays on your
11
11
  bunx @homescriptone/opencode-viewer-extender
12
12
  ```
13
13
 
14
- > On npm: [`@homescriptone/opencode-viewer-extender`](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender) · Desktop builds (`.dmg`, Linux, Windows) are published on the [releases page](https://github.com/homescriptone/opencode-viewer-extended/releases).
14
+ > On npm: [`@homescriptone/opencode-viewer-extender`](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender).
15
15
 
16
16
  ## Features
17
17
 
@@ -25,7 +25,7 @@ bunx @homescriptone/opencode-viewer-extender
25
25
 
26
26
  ## Requirements
27
27
 
28
- - [Bun](https://bun.sh) runtime (`curl -fsSL https://bun.sh/install | bash`), or a desktop build
28
+ - [Bun](https://bun.sh) runtime (`curl -fsSL https://bun.sh/install | bash`)
29
29
  - [OpenCode](https://opencode.ai) installed and used at least once
30
30
 
31
31
  ## Usage
@@ -58,7 +58,7 @@ The database is opened **read-only** by default. Writes only become available wh
58
58
  ## Export
59
59
 
60
60
  - **Single session** — the **Export ▾** menu in the message header offers Markdown (`.md`) and JSON (`.json`), served by `GET /api/sessions/:id/export?format=md|json`.
61
- - **Bulk** — turn on **Select**, pick multiple projects/sessions, and choose **Export selected (.zip)**. The archive is grouped into folders, and every item is written in three formats (`.json`, `.xml`, `.html`):
61
+ - **Bulk** — turn on **Select**, use **All projects** / **All sessions** (or pick individual items), and choose **Export selected (.zip)**. A progress indicator appears for larger exports. The archive is grouped into folders, and every item is written in three formats (`.json`, `.xml`, `.html`):
62
62
 
63
63
  ```
64
64
  manifest.json
@@ -157,11 +157,15 @@ bun run typecheck
157
157
 
158
158
  ## Releasing
159
159
 
160
- Publishing to npm is automated: pushing to `main`/`master` runs [`.github/workflows/publish.yml`](.github/workflows/publish.yml), which type-checks and publishes the current `package.json` version (skipping if that version already exists). Add an `NPM_TOKEN` repository secret (an npm automation token) for it to publish. Bumping the version is all you need to ship a release.
160
+ Publishing uses npm **trusted publishing (OIDC)** — no tokens or secrets. Push a version tag:
161
161
 
162
- ## Desktop app
162
+ ```bash
163
+ git tag v1.3.0 && git push origin v1.3.0
164
+ ```
165
+
166
+ [`.github/workflows/publish.yml`](.github/workflows/publish.yml) then type-checks and runs `npm publish` with provenance.
163
167
 
164
- A Tauri wrapper lives in [`src-tauri/`](src-tauri/) and bundles the Bun server as a sidecar; installers are produced by CI and attached to releases.
168
+ One-time setup on [npmjs.com](https://www.npmjs.com): open the package → **Settings** → **Trusted Publisher** → **GitHub Actions**, and set the repository (`homescriptone/opencode-viewer-extended`) and workflow filename (`publish.yml`). No `NPM_TOKEN` secret is required.
165
169
 
166
170
  ## Credits
167
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homescriptone/opencode-viewer-extender",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Local GUI to browse, edit, and export your OpenCode sessions, messages, and tool calls from the SQLite database, with a plugin API",
5
5
  "bin": {
6
6
  "opencode-viewer": "bin/cli.js"
package/public/index.html CHANGED
@@ -16,6 +16,7 @@
16
16
  <main id="messages-panel" class="panel"></main>
17
17
  </div>
18
18
  <div id="bulk-bar" class="bulk-bar" hidden></div>
19
+ <div id="status-toast" class="status-toast" hidden></div>
19
20
  <div id="modal-root"></div>
20
21
  <script src="plugin-api.js"></script>
21
22
  <script src="script.js"></script>
@@ -26,55 +26,73 @@ OCV.define({
26
26
  const seenSessions = new Set();
27
27
  const projectsById = new Map(host.getProjects().map((project) => [project.id, project]));
28
28
 
29
- // Write one folder of json/xml/html for a { session, messages } payload.
30
- const addSession = async (sessionId, folder) => {
31
- if (seenSessions.has(sessionId)) return;
32
- seenSessions.add(sessionId);
29
+ try {
30
+ host.setStatus("Preparing export…");
33
31
 
34
- const detail = await host.api.getSessionDetail(sessionId);
35
- const title = detail?.session?.title || sessionId;
36
- entries.push({ name: `${folder}/session.json`, data: JSON.stringify(detail, null, 2) });
37
- entries.push({ name: `${folder}/session.xml`, data: toXmlDocument("export", detail) });
38
- entries.push({ name: `${folder}/session.html`, data: toHtmlDocument(title, detail) });
39
- };
32
+ const tasks = [];
33
+ for (const projectId of projectIds) {
34
+ const project = projectsById.get(projectId) || { id: projectId };
35
+ const folderName = sanitize(`${project.name || projectId}-${projectId}`);
36
+ const base = `projects/${folderName}`;
40
37
 
41
- for (const projectId of projectIds) {
42
- const project = projectsById.get(projectId) || { id: projectId };
43
- const folderName = sanitize(`${project.name || projectId}-${projectId}`);
44
- const base = `projects/${folderName}`;
38
+ entries.push({ name: `${base}/project.json`, data: JSON.stringify(project, null, 2) });
39
+ entries.push({ name: `${base}/project.xml`, data: toXmlDocument("project", project) });
40
+ entries.push({ name: `${base}/project.html`, data: toProjectHtml(project) });
45
41
 
46
- entries.push({ name: `${base}/project.json`, data: JSON.stringify(project, null, 2) });
47
- entries.push({ name: `${base}/project.xml`, data: toXmlDocument("project", project) });
48
- entries.push({ name: `${base}/project.html`, data: toProjectHtml(project) });
42
+ const sessions = await host.api.getProjectSessions(projectId);
43
+ for (const session of sessions) {
44
+ if (seenSessions.has(session.id)) continue;
45
+ seenSessions.add(session.id);
46
+ tasks.push({ sessionId: session.id, folder: `${base}/sessions/${sanitize(session.id)}` });
47
+ }
48
+ }
49
49
 
50
- const sessions = await host.api.getProjectSessions(projectId);
51
- for (const session of sessions) {
52
- await addSession(session.id, `${base}/sessions/${sanitize(session.id)}`);
50
+ for (const sessionId of sessionIds) {
51
+ if (seenSessions.has(sessionId)) continue;
52
+ seenSessions.add(sessionId);
53
+ tasks.push({ sessionId, folder: `sessions/${sanitize(sessionId)}` });
53
54
  }
54
- }
55
55
 
56
- for (const sessionId of sessionIds) {
57
- await addSession(sessionId, `sessions/${sanitize(sessionId)}`);
58
- }
56
+ const total = tasks.length;
57
+ let done = 0;
58
+ if (total === 0) {
59
+ host.setStatus("Building archive…");
60
+ } else {
61
+ host.setProgress(0, total, "Collecting sessions…");
62
+ }
63
+
64
+ for (const task of tasks) {
65
+ const detail = await host.api.getSessionDetail(task.sessionId);
66
+ const title = detail?.session?.title || task.sessionId;
67
+ entries.push({ name: `${task.folder}/session.json`, data: JSON.stringify(detail, null, 2) });
68
+ entries.push({ name: `${task.folder}/session.xml`, data: toXmlDocument("export", detail) });
69
+ entries.push({ name: `${task.folder}/session.html`, data: toHtmlDocument(title, detail) });
70
+ done += 1;
71
+ host.setProgress(done, total, "Collecting sessions…");
72
+ }
59
73
 
60
- entries.push({
61
- name: "manifest.json",
62
- data: JSON.stringify(
63
- {
64
- exportedAt: new Date().toISOString(),
65
- formats: ["json", "xml", "html"],
66
- projectIds,
67
- sessionIds,
68
- files: entries.length + 1
69
- },
70
- null,
71
- 2
72
- )
73
- });
74
+ host.setStatus("Building archive…");
75
+ entries.push({
76
+ name: "manifest.json",
77
+ data: JSON.stringify(
78
+ {
79
+ exportedAt: new Date().toISOString(),
80
+ formats: ["json", "xml", "html"],
81
+ projectIds,
82
+ sessionIds,
83
+ files: entries.length + 1
84
+ },
85
+ null,
86
+ 2
87
+ )
88
+ });
74
89
 
75
- const blob = host.createZip(entries);
76
- host.download(`ocv-export-${Date.now()}.zip`, blob, "application/zip");
77
- host.notify(`Exported ${entries.length} files (json/xml/html).`);
90
+ const blob = host.createZip(entries);
91
+ host.download(`ocv-export-${Date.now()}.zip`, blob, "application/zip");
92
+ host.notify(`Exported ${entries.length} files (json/xml/html).`);
93
+ } finally {
94
+ host.clearStatus();
95
+ }
78
96
  }
79
97
  });
80
98
 
@@ -92,14 +110,22 @@ OCV.define({
92
110
  const ok = window.confirm(`Delete ${parts.join(" and ")}? This cannot be undone.`);
93
111
  if (!ok) return;
94
112
 
113
+ const total = projectIds.length + sessionIds.length;
95
114
  let deleted = 0;
96
- for (const projectId of projectIds) {
97
- await host.api.deleteProject(projectId);
98
- deleted += 1;
99
- }
100
- for (const sessionId of sessionIds) {
101
- await host.api.deleteSession(sessionId);
102
- deleted += 1;
115
+ try {
116
+ host.setProgress(0, total, "Deleting…");
117
+ for (const projectId of projectIds) {
118
+ await host.api.deleteProject(projectId);
119
+ deleted += 1;
120
+ host.setProgress(deleted, total, "Deleting…");
121
+ }
122
+ for (const sessionId of sessionIds) {
123
+ await host.api.deleteSession(sessionId);
124
+ deleted += 1;
125
+ host.setProgress(deleted, total, "Deleting…");
126
+ }
127
+ } finally {
128
+ host.clearStatus();
103
129
  }
104
130
 
105
131
  host.clearSelection();
package/public/script.js CHANGED
@@ -28,7 +28,8 @@
28
28
  selectedSessionIds: new Set(),
29
29
  pluginsOpen: false,
30
30
  writeEnabled: false,
31
- editMode: false
31
+ editMode: false,
32
+ status: null
32
33
  };
33
34
 
34
35
  const el = {
@@ -36,6 +37,7 @@
36
37
  sessions: document.getElementById("sessions-panel"),
37
38
  messages: document.getElementById("messages-panel"),
38
39
  bulkBar: document.getElementById("bulk-bar"),
40
+ statusToast: document.getElementById("status-toast"),
39
41
  modal: document.getElementById("modal-root")
40
42
  };
41
43
 
@@ -745,15 +747,49 @@
745
747
  renderBulkBar();
746
748
  }
747
749
 
750
+ function renderStatus() {
751
+ if (!el.statusToast) return;
752
+ const status = state.status;
753
+ if (!status || (!status.label && status.current === undefined)) {
754
+ el.statusToast.hidden = true;
755
+ el.statusToast.innerHTML = "";
756
+ return;
757
+ }
758
+
759
+ const { label = "", current, total } = status;
760
+ let progressHtml = "";
761
+ if (typeof current === "number" && typeof total === "number" && total > 0) {
762
+ const pct = Math.max(0, Math.min(100, Math.round((current / total) * 100)));
763
+ progressHtml = `
764
+ <div class="status-progress"><div class="status-progress-fill" style="width:${pct}%"></div></div>
765
+ <div class="status-count">${current} / ${total}</div>
766
+ `;
767
+ }
768
+
769
+ el.statusToast.hidden = false;
770
+ el.statusToast.innerHTML = `
771
+ <span class="status-spinner" aria-hidden="true"></span>
772
+ <div class="status-body">
773
+ <div class="status-label">${escapeHtml(label || "Working…")}</div>
774
+ ${progressHtml}
775
+ </div>
776
+ `;
777
+ }
778
+
748
779
  function renderBulkBar() {
749
780
  if (!el.bulkBar) return;
750
- const count = state.selectedProjectIds.size + state.selectedSessionIds.size;
751
- if (!state.selectMode || count === 0) {
781
+ if (!state.selectMode) {
752
782
  el.bulkBar.hidden = true;
753
783
  el.bulkBar.innerHTML = "";
754
784
  return;
755
785
  }
756
786
 
787
+ const count = state.selectedProjectIds.size + state.selectedSessionIds.size;
788
+ const allProjectsSelected =
789
+ state.projects.length > 0 && state.projects.every((project) => state.selectedProjectIds.has(project.id));
790
+ const allSessionsSelected =
791
+ state.sessions.length > 0 && state.sessions.every((session) => state.selectedSessionIds.has(session.id));
792
+
757
793
  const buttons = (window.OCV?.listBulkActions?.() ?? [])
758
794
  .filter((action) => !action.write || (state.writeEnabled && state.editMode))
759
795
  .map(
@@ -765,8 +801,10 @@
765
801
  el.bulkBar.hidden = false;
766
802
  el.bulkBar.innerHTML = `
767
803
  <span class="bulk-count">${count} selected</span>
768
- ${buttons}
769
- <button class="bulk-btn ghost" data-action="bulk-clear">Clear</button>
804
+ <button class="bulk-btn ${allProjectsSelected ? "active" : ""}" data-action="bulk-select-all-projects"${state.projects.length === 0 ? " disabled" : ""}>All projects</button>
805
+ <button class="bulk-btn ${allSessionsSelected ? "active" : ""}" data-action="bulk-select-all-sessions"${state.sessions.length === 0 ? " disabled" : ""}>All sessions</button>
806
+ <button class="bulk-btn ghost" data-action="bulk-clear"${count === 0 ? " disabled" : ""}>Clear</button>
807
+ ${count > 0 ? buttons : ""}
770
808
  `;
771
809
  }
772
810
 
@@ -1114,7 +1152,12 @@
1114
1152
  )
1115
1153
  .join("");
1116
1154
 
1117
- return `<div class="header-actions">${pluginActions}${exportMenu}</div>`;
1155
+ const editActions =
1156
+ state.writeEnabled && state.editMode
1157
+ ? `<button class="header-btn danger" data-action="delete-session">Delete</button>`
1158
+ : "";
1159
+
1160
+ return `<div class="header-actions">${editActions}${pluginActions}${exportMenu}</div>`;
1118
1161
  }
1119
1162
 
1120
1163
  function renderMessagesPanel() {
@@ -1545,10 +1588,31 @@
1545
1588
  return;
1546
1589
  }
1547
1590
 
1591
+ if (action === "delete-session") {
1592
+ const sessionId = state.selectedSessionId;
1593
+ if (!sessionId) return;
1594
+ const title = state.sessionData?.title || getSelectedSession()?.title || sessionId;
1595
+ const ok = window.confirm(`Delete session "${title}"? This cannot be undone.`);
1596
+ if (!ok) return;
1597
+ try {
1598
+ window.OCV?.host?.setStatus?.("Deleting session…");
1599
+ await window.OCV.host.api.deleteSession(sessionId);
1600
+ clearSelectedSession();
1601
+ await loadSessions({ reset: true });
1602
+ } catch (error) {
1603
+ console.error("Delete session failed:", error);
1604
+ alert(`Delete failed: ${error instanceof Error ? error.message : String(error)}`);
1605
+ } finally {
1606
+ window.OCV?.host?.clearStatus?.();
1607
+ }
1608
+ return;
1609
+ }
1610
+
1548
1611
  if (action === "toggle-edit-mode") {
1549
1612
  state.editMode = !state.editMode;
1550
1613
  renderProjectsPanel();
1551
1614
  renderSessionsPanel();
1615
+ renderMessagesPanel();
1552
1616
  renderBulkBar();
1553
1617
  return;
1554
1618
  }
@@ -1585,6 +1649,32 @@
1585
1649
  return;
1586
1650
  }
1587
1651
 
1652
+ if (action === "bulk-select-all-projects") {
1653
+ const allSelected =
1654
+ state.projects.length > 0 && state.projects.every((project) => state.selectedProjectIds.has(project.id));
1655
+ if (allSelected) {
1656
+ for (const project of state.projects) state.selectedProjectIds.delete(project.id);
1657
+ } else {
1658
+ for (const project of state.projects) state.selectedProjectIds.add(project.id);
1659
+ }
1660
+ renderProjectsPanel();
1661
+ renderBulkBar();
1662
+ return;
1663
+ }
1664
+
1665
+ if (action === "bulk-select-all-sessions") {
1666
+ const allSelected =
1667
+ state.sessions.length > 0 && state.sessions.every((session) => state.selectedSessionIds.has(session.id));
1668
+ if (allSelected) {
1669
+ for (const session of state.sessions) state.selectedSessionIds.delete(session.id);
1670
+ } else {
1671
+ for (const session of state.sessions) state.selectedSessionIds.add(session.id);
1672
+ }
1673
+ renderSessionsPanel();
1674
+ renderBulkBar();
1675
+ return;
1676
+ }
1677
+
1588
1678
  if (action === "bulk-clear") {
1589
1679
  state.selectedProjectIds.clear();
1590
1680
  state.selectedSessionIds.clear();
@@ -1768,6 +1858,7 @@
1768
1858
  renderMessagesPanel();
1769
1859
  renderBulkBar();
1770
1860
  renderPluginsDialog();
1861
+ renderStatus();
1771
1862
  }
1772
1863
 
1773
1864
  if (window.OCV?.host) {
@@ -1788,6 +1879,18 @@
1788
1879
  download: (filename, content, mime) => downloadBlob(content, filename, mime),
1789
1880
  createZip: (entries) => window.OCV.helpers.createZip(entries),
1790
1881
  notify: (message) => console.log("[OCV]", message),
1882
+ setStatus: (label) => {
1883
+ state.status = { label };
1884
+ renderStatus();
1885
+ },
1886
+ setProgress: (current, total, label) => {
1887
+ state.status = { label, current, total };
1888
+ renderStatus();
1889
+ },
1890
+ clearStatus: () => {
1891
+ state.status = null;
1892
+ renderStatus();
1893
+ },
1791
1894
  refresh: () => {
1792
1895
  loadProjects();
1793
1896
  if (state.selectedProjectId) loadSessions({ reset: true });
package/public/styles.css CHANGED
@@ -995,6 +995,16 @@
995
995
  color: var(--accent-ink);
996
996
  }
997
997
 
998
+ .header-btn.danger {
999
+ color: #b23b3b;
1000
+ border-color: #e4bcbc;
1001
+ }
1002
+
1003
+ .header-btn.danger:hover {
1004
+ background: #fbeaea;
1005
+ border-color: #d99a9a;
1006
+ }
1007
+
998
1008
  .project-row.selectable,
999
1009
  .session-card.selectable {
1000
1010
  display: flex;
@@ -1083,6 +1093,22 @@
1083
1093
  background: var(--accent-wash);
1084
1094
  }
1085
1095
 
1096
+ .bulk-btn.active {
1097
+ border-color: var(--accent-hair);
1098
+ background: var(--accent-wash);
1099
+ color: var(--accent-ink);
1100
+ }
1101
+
1102
+ .bulk-btn:disabled {
1103
+ opacity: 0.5;
1104
+ cursor: default;
1105
+ }
1106
+
1107
+ .bulk-btn:disabled:hover {
1108
+ background: none;
1109
+ border-color: var(--hair);
1110
+ }
1111
+
1086
1112
  .bulk-btn.danger {
1087
1113
  color: #b23b3b;
1088
1114
  border-color: #e4bcbc;
@@ -1097,6 +1123,69 @@
1097
1123
  color: var(--muted);
1098
1124
  }
1099
1125
 
1126
+ .status-toast {
1127
+ position: fixed;
1128
+ right: 20px;
1129
+ bottom: 20px;
1130
+ z-index: 450;
1131
+ display: flex;
1132
+ align-items: center;
1133
+ gap: 10px;
1134
+ min-width: 220px;
1135
+ max-width: 340px;
1136
+ padding: 12px 14px;
1137
+ background: var(--surface);
1138
+ border: 1px solid var(--hair-2);
1139
+ border-radius: 12px;
1140
+ box-shadow: 0 10px 30px rgba(20, 20, 20, 0.16);
1141
+ }
1142
+
1143
+ .status-spinner {
1144
+ flex: 0 0 auto;
1145
+ width: 16px;
1146
+ height: 16px;
1147
+ border: 2px solid var(--hair-2);
1148
+ border-top-color: var(--accent);
1149
+ border-radius: 50%;
1150
+ animation: status-spin 0.7s linear infinite;
1151
+ }
1152
+
1153
+ @keyframes status-spin {
1154
+ to { transform: rotate(360deg); }
1155
+ }
1156
+
1157
+ .status-body {
1158
+ flex: 1;
1159
+ min-width: 0;
1160
+ }
1161
+
1162
+ .status-label {
1163
+ font-size: 12.5px;
1164
+ color: var(--ink);
1165
+ }
1166
+
1167
+ .status-progress {
1168
+ margin-top: 8px;
1169
+ height: 6px;
1170
+ border-radius: 999px;
1171
+ background: var(--hair);
1172
+ overflow: hidden;
1173
+ }
1174
+
1175
+ .status-progress-fill {
1176
+ height: 100%;
1177
+ background: var(--accent);
1178
+ border-radius: 999px;
1179
+ transition: width 0.2s ease;
1180
+ }
1181
+
1182
+ .status-count {
1183
+ margin-top: 4px;
1184
+ font-size: 11px;
1185
+ color: var(--muted);
1186
+ font-variant-numeric: tabular-nums;
1187
+ }
1188
+
1100
1189
  .modal-overlay {
1101
1190
  position: fixed;
1102
1191
  inset: 0;