codedash-app 1.7.0 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codedash-app",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Termius-style browser dashboard for Claude Code sessions. View, search, resume, and delete sessions with a dark-themed UI.",
5
5
  "bin": {
6
6
  "codedash": "./bin/cli.js"
package/src/data.js CHANGED
@@ -437,60 +437,103 @@ function getSessionPreview(sessionId, project, limit) {
437
437
  return messages;
438
438
  }
439
439
 
440
- // ── Full-text search across all sessions ──────────────────
440
+ // ── Full-text search index ─────────────────────────────────
441
+ //
442
+ // Built once on first search, then cached in memory.
443
+ // Each entry: { sessionId, texts: [{role, content}] }
444
+ // Total text is kept lowercase for fast substring matching.
441
445
 
442
- function searchFullText(query, sessions) {
443
- if (!query || query.length < 2) return [];
444
- const q = query.toLowerCase();
445
- const results = [];
446
+ let searchIndex = null;
447
+ let searchIndexBuiltAt = 0;
448
+ const INDEX_TTL = 60000; // rebuild every 60s
449
+
450
+ function buildSearchIndex(sessions) {
451
+ const startMs = Date.now();
452
+ const index = [];
446
453
 
447
454
  for (const s of sessions) {
448
- if (s.tool !== 'claude' || !s.has_detail) continue;
455
+ if (!s.has_detail) continue;
449
456
 
450
- const projectKey = s.project.replace(/[\/\.]/g, '-');
451
- const sessionFile = path.join(PROJECTS_DIR, projectKey, `${s.id}.jsonl`);
452
- if (!fs.existsSync(sessionFile)) continue;
457
+ const found = findSessionFile(s.id, s.project);
458
+ if (!found) continue;
453
459
 
454
460
  try {
455
- const data = fs.readFileSync(sessionFile, 'utf8');
456
- // Quick check before parsing
457
- if (data.toLowerCase().indexOf(q) === -1) continue;
461
+ const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
462
+ const texts = [];
458
463
 
459
- // Find matching messages
460
- const lines = data.split('\n').filter(Boolean);
461
- const matches = [];
462
464
  for (const line of lines) {
463
- if (matches.length >= 3) break; // max 3 matches per session
464
465
  try {
465
466
  const entry = JSON.parse(line);
466
- if (entry.type !== 'user' && entry.type !== 'assistant') continue;
467
- const msg = entry.message || {};
468
- let content = msg.content || '';
469
- if (Array.isArray(content)) {
470
- content = content
471
- .map(b => (typeof b === 'string' ? b : (b.type === 'text' ? b.text : '')))
472
- .filter(Boolean)
473
- .join('\n');
467
+ let role, content;
468
+
469
+ if (found.format === 'claude') {
470
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
471
+ role = entry.type;
472
+ content = extractContent((entry.message || {}).content);
473
+ } else {
474
+ if (entry.type !== 'response_item' || !entry.payload) continue;
475
+ role = entry.payload.role;
476
+ if (role !== 'user' && role !== 'assistant') continue;
477
+ content = extractContent(entry.payload.content);
474
478
  }
475
- if (content.toLowerCase().indexOf(q) >= 0) {
476
- // Extract snippet around match
477
- const idx = content.toLowerCase().indexOf(q);
478
- const start = Math.max(0, idx - 50);
479
- const end = Math.min(content.length, idx + q.length + 50);
480
- matches.push({
481
- role: entry.type,
482
- snippet: (start > 0 ? '...' : '') + content.slice(start, end) + (end < content.length ? '...' : ''),
483
- });
479
+
480
+ if (content && !isSystemMessage(content)) {
481
+ texts.push({ role, content: content.slice(0, 500) });
484
482
  }
485
483
  } catch {}
486
484
  }
487
485
 
488
- if (matches.length > 0) {
489
- results.push({ sessionId: s.id, matches });
486
+ if (texts.length > 0) {
487
+ // Pre-compute lowercase full text for fast matching
488
+ const fullText = texts.map(t => t.content).join(' ').toLowerCase();
489
+ index.push({ sessionId: s.id, texts, fullText });
490
490
  }
491
491
  } catch {}
492
492
  }
493
493
 
494
+ const elapsed = Date.now() - startMs;
495
+ console.log(` \x1b[2mSearch index: ${index.length} sessions, ${elapsed}ms\x1b[0m`);
496
+ return index;
497
+ }
498
+
499
+ function getSearchIndex(sessions) {
500
+ const now = Date.now();
501
+ if (!searchIndex || (now - searchIndexBuiltAt) > INDEX_TTL) {
502
+ searchIndex = buildSearchIndex(sessions);
503
+ searchIndexBuiltAt = now;
504
+ }
505
+ return searchIndex;
506
+ }
507
+
508
+ function searchFullText(query, sessions) {
509
+ if (!query || query.length < 2) return [];
510
+ const q = query.toLowerCase();
511
+ const index = getSearchIndex(sessions);
512
+ const results = [];
513
+
514
+ for (const entry of index) {
515
+ if (entry.fullText.indexOf(q) === -1) continue;
516
+
517
+ // Find matching messages with snippets
518
+ const matches = [];
519
+ for (const t of entry.texts) {
520
+ if (matches.length >= 3) break;
521
+ const idx = t.content.toLowerCase().indexOf(q);
522
+ if (idx >= 0) {
523
+ const start = Math.max(0, idx - 50);
524
+ const end = Math.min(t.content.length, idx + q.length + 50);
525
+ matches.push({
526
+ role: t.role,
527
+ snippet: (start > 0 ? '...' : '') + t.content.slice(start, end) + (end < t.content.length ? '...' : ''),
528
+ });
529
+ }
530
+ }
531
+
532
+ if (matches.length > 0) {
533
+ results.push({ sessionId: entry.sessionId, matches });
534
+ }
535
+ }
536
+
494
537
  return results;
495
538
  }
496
539
 
@@ -104,10 +104,20 @@ function showTagDropdown(event, sessionId) {
104
104
  document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); });
105
105
  var dd = document.createElement('div');
106
106
  dd.className = 'tag-dropdown';
107
+ var existingTags = tags[sessionId] || [];
107
108
  dd.innerHTML = TAG_OPTIONS.map(function(t) {
108
- return '<div class="tag-dropdown-item" onclick="event.stopPropagation();addTag(\'' + sessionId + '\',\'' + t + '\')">' + t + '</div>';
109
+ var has = existingTags.indexOf(t) >= 0;
110
+ return '<div class="tag-dropdown-item" onclick="event.stopPropagation();' +
111
+ (has ? 'removeTag' : 'addTag') + '(\'' + sessionId + '\',\'' + t + '\')">' +
112
+ (has ? '&#10003; ' : '') + t + '</div>';
109
113
  }).join('');
110
- event.target.parentElement.appendChild(dd);
114
+
115
+ // Position near the button
116
+ var rect = event.target.getBoundingClientRect();
117
+ dd.style.top = (rect.bottom + 4) + 'px';
118
+ dd.style.left = rect.left + 'px';
119
+
120
+ document.body.appendChild(dd);
111
121
  setTimeout(function() {
112
122
  document.addEventListener('click', function() { dd.remove(); }, { once: true });
113
123
  }, 0);
@@ -1338,6 +1348,30 @@ document.addEventListener('keydown', function(e) {
1338
1348
  }
1339
1349
  });
1340
1350
 
1351
+ // ── Export/Import dialog ──────────────────────────────────────
1352
+
1353
+ function showExportDialog() {
1354
+ var overlay = document.getElementById('confirmOverlay');
1355
+ document.getElementById('confirmTitle').textContent = 'Export / Import Sessions';
1356
+ document.getElementById('confirmText').innerHTML =
1357
+ '<strong>Export</strong> all sessions to migrate to another PC:<br>' +
1358
+ '<code style="display:block;margin:8px 0;padding:8px;background:var(--bg-card);border-radius:6px;font-size:12px">codedash export</code>' +
1359
+ 'Creates a tar.gz with all Claude &amp; Codex session data.<br><br>' +
1360
+ '<strong>Import</strong> on the new machine:<br>' +
1361
+ '<code style="display:block;margin:8px 0;padding:8px;background:var(--bg-card);border-radius:6px;font-size:12px">codedash import &lt;file.tar.gz&gt;</code>' +
1362
+ '<br><em style="color:var(--text-muted);font-size:12px">Don\'t forget to clone your git repos separately.</em>';
1363
+ document.getElementById('confirmId').textContent = '';
1364
+ document.getElementById('confirmAction').textContent = 'Copy Export Command';
1365
+ document.getElementById('confirmAction').className = 'launch-btn btn-primary';
1366
+ document.getElementById('confirmAction').onclick = function() {
1367
+ navigator.clipboard.writeText('codedash export').then(function() {
1368
+ showToast('Copied: codedash export');
1369
+ });
1370
+ closeConfirm();
1371
+ };
1372
+ if (overlay) overlay.style.display = 'flex';
1373
+ }
1374
+
1341
1375
  // ── Update check ──────────────────────────────────────────────
1342
1376
 
1343
1377
  async function checkForUpdates() {
@@ -44,6 +44,11 @@
44
44
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
45
45
  Codex
46
46
  </div>
47
+ <div class="sidebar-divider"></div>
48
+ <div class="sidebar-item" onclick="showExportDialog()">
49
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
50
+ Export / Import
51
+ </div>
47
52
  <div class="sidebar-settings">
48
53
  <label>Terminal</label>
49
54
  <select id="terminalSelect" onchange="saveTerminalPref(this.value)">
@@ -484,20 +484,15 @@ body {
484
484
  }
485
485
 
486
486
  .tag-dropdown {
487
- position: absolute;
488
- top: 100%;
489
- left: 0;
490
- margin-top: 4px;
487
+ position: fixed;
491
488
  background: var(--bg-secondary);
492
489
  border: 1px solid var(--border);
493
490
  border-radius: 8px;
494
491
  padding: 6px;
495
492
  min-width: 140px;
496
493
  box-shadow: 0 8px 24px rgba(0,0,0,0.3);
497
- z-index: 50;
498
- display: none;
494
+ z-index: 150;
499
495
  }
500
- .tag-dropdown.open { display: block; }
501
496
 
502
497
  .tag-dropdown-item {
503
498
  display: flex;