@ycniuqton/devlens 0.1.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.
Files changed (61) hide show
  1. package/README.md +164 -0
  2. package/bin/devlens.js +2 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.js +205 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/init.d.ts +3 -0
  7. package/dist/init.js +239 -0
  8. package/dist/init.js.map +1 -0
  9. package/dist/routes/diff.d.ts +1 -0
  10. package/dist/routes/diff.js +39 -0
  11. package/dist/routes/diff.js.map +1 -0
  12. package/dist/routes/integrations.d.ts +1 -0
  13. package/dist/routes/integrations.js +132 -0
  14. package/dist/routes/integrations.js.map +1 -0
  15. package/dist/routes/rules.d.ts +1 -0
  16. package/dist/routes/rules.js +115 -0
  17. package/dist/routes/rules.js.map +1 -0
  18. package/dist/routes/tasks.d.ts +4 -0
  19. package/dist/routes/tasks.js +360 -0
  20. package/dist/routes/tasks.js.map +1 -0
  21. package/dist/server.d.ts +7 -0
  22. package/dist/server.js +112 -0
  23. package/dist/server.js.map +1 -0
  24. package/dist/services/claudeTasks.d.ts +23 -0
  25. package/dist/services/claudeTasks.js +160 -0
  26. package/dist/services/claudeTasks.js.map +1 -0
  27. package/dist/services/config.d.ts +3 -0
  28. package/dist/services/config.js +25 -0
  29. package/dist/services/config.js.map +1 -0
  30. package/dist/services/git.d.ts +8 -0
  31. package/dist/services/git.js +90 -0
  32. package/dist/services/git.js.map +1 -0
  33. package/dist/services/jira.d.ts +11 -0
  34. package/dist/services/jira.js +52 -0
  35. package/dist/services/jira.js.map +1 -0
  36. package/dist/services/linear.d.ts +9 -0
  37. package/dist/services/linear.js +69 -0
  38. package/dist/services/linear.js.map +1 -0
  39. package/dist/services/rules.d.ts +14 -0
  40. package/dist/services/rules.js +133 -0
  41. package/dist/services/rules.js.map +1 -0
  42. package/dist/services/taskStore.d.ts +27 -0
  43. package/dist/services/taskStore.js +261 -0
  44. package/dist/services/taskStore.js.map +1 -0
  45. package/dist/services/tunnel.d.ts +8 -0
  46. package/dist/services/tunnel.js +152 -0
  47. package/dist/services/tunnel.js.map +1 -0
  48. package/dist/services/watcher.d.ts +2 -0
  49. package/dist/services/watcher.js +30 -0
  50. package/dist/services/watcher.js.map +1 -0
  51. package/dist/types/index.d.ts +87 -0
  52. package/dist/types/index.js +3 -0
  53. package/dist/types/index.js.map +1 -0
  54. package/package.json +53 -0
  55. package/public/css/style.css +1613 -0
  56. package/public/index.html +395 -0
  57. package/public/js/app.js +104 -0
  58. package/public/js/diff.js +337 -0
  59. package/public/js/integrations.js +194 -0
  60. package/public/js/rules.js +174 -0
  61. package/public/js/tasks.js +301 -0
@@ -0,0 +1,337 @@
1
+ // Diff viewer
2
+ var currentFilter = 'all';
3
+ var currentViewMode = 'line-by-line';
4
+ var fileListMode = localStorage.getItem('devlens-file-view') || 'flat';
5
+ var diffFiles = [];
6
+ var currentFiles = [];
7
+
8
+ // Restore file list view mode from localStorage
9
+ (function restoreFileViewMode() {
10
+ const flatBtn = document.getElementById('file-view-flat');
11
+ const treeBtn = document.getElementById('file-view-tree');
12
+ if (fileListMode === 'tree') {
13
+ treeBtn?.classList.add('active');
14
+ flatBtn?.classList.remove('active');
15
+ } else {
16
+ flatBtn?.classList.add('active');
17
+ treeBtn?.classList.remove('active');
18
+ }
19
+ })();
20
+
21
+ // Filter and view toggle
22
+ document.querySelector('.header-actions')?.addEventListener('click', (e) => {
23
+ const btn = e.target.closest('[data-filter]') || e.target.closest('[data-view]');
24
+ if (!btn) return;
25
+
26
+ if (btn.dataset.filter !== undefined) {
27
+ document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
28
+ btn.classList.add('active');
29
+ currentFilter = btn.dataset.filter;
30
+ loadDiff();
31
+ }
32
+
33
+ if (btn.dataset.view !== undefined) {
34
+ document.querySelectorAll('[data-view]').forEach(b => b.classList.remove('active'));
35
+ btn.classList.add('active');
36
+ currentViewMode = btn.dataset.view;
37
+ renderAllFiles();
38
+ }
39
+ });
40
+
41
+ // File list click → collapse all, expand clicked, scroll to it
42
+ document.getElementById('file-list-items').addEventListener('click', (e) => {
43
+ const li = e.target.closest('li');
44
+ if (!li || li.classList.contains('empty-state-inline')) return;
45
+
46
+ // Folder click → toggle expand/collapse
47
+ if (li.classList.contains('tree-folder')) {
48
+ const folderPath = li.dataset.folder;
49
+ if (folderPath) toggleFolder(folderPath);
50
+ return;
51
+ }
52
+
53
+ const fileName = li.getAttribute('title');
54
+ if (!fileName) return;
55
+
56
+ document.querySelectorAll('#file-list-items li').forEach(l => l.classList.remove('selected'));
57
+ li.classList.add('selected');
58
+
59
+ // Collapse all, expand only the clicked file
60
+ const sections = document.querySelectorAll('.diff-file-section');
61
+ for (const section of sections) {
62
+ if (section.dataset.file === fileName) {
63
+ section.classList.add('expanded');
64
+ } else {
65
+ section.classList.remove('expanded');
66
+ }
67
+ }
68
+
69
+ // Wait for reflow, then scroll with fixed header offset
70
+ setTimeout(() => {
71
+ const target = document.querySelector(`.diff-file-section[data-file="${fileName}"]`);
72
+ if (target) {
73
+ const headerOffset = 80;
74
+ const top = target.getBoundingClientRect().top + window.scrollY - headerOffset;
75
+ window.scrollTo({ top, behavior: 'smooth' });
76
+ }
77
+ }, 50);
78
+ });
79
+
80
+ // Expand all / Collapse all folders (tree view only)
81
+ function getAllFolderPaths(files) {
82
+ const folderSet = new Set();
83
+ for (const f of files) {
84
+ const parts = f.path.split('/');
85
+ let current = '';
86
+ for (let i = 0; i < parts.length - 1; i++) {
87
+ current = current ? `${current}/${parts[i]}` : parts[i];
88
+ folderSet.add(current);
89
+ }
90
+ }
91
+ return Array.from(folderSet);
92
+ }
93
+
94
+ document.getElementById('expand-all-btn')?.addEventListener('click', () => {
95
+ collapsedFolders.clear();
96
+ saveCollapsedFolders();
97
+ renderFileList(currentFiles);
98
+ });
99
+
100
+ document.getElementById('collapse-all-btn')?.addEventListener('click', () => {
101
+ const allFolders = getAllFolderPaths(currentFiles);
102
+ collapsedFolders = new Set(allFolders);
103
+ saveCollapsedFolders();
104
+ renderFileList(currentFiles);
105
+ });
106
+
107
+ // Word wrap toggle
108
+ document.getElementById('toggle-wrap')?.addEventListener('click', (e) => {
109
+ const btn = e.currentTarget;
110
+ btn.classList.toggle('active');
111
+ document.getElementById('diff-output').classList.toggle('word-wrap');
112
+ });
113
+
114
+ // File list view mode toggles — persist to localStorage
115
+ function updateExpandButtonsVisibility() {
116
+ const visible = fileListMode === 'tree';
117
+ const expandBtn = document.getElementById('expand-all-btn');
118
+ const collapseBtn = document.getElementById('collapse-all-btn');
119
+ const divider = document.querySelector('.file-list-divider');
120
+ if (expandBtn) expandBtn.style.display = visible ? '' : 'none';
121
+ if (collapseBtn) collapseBtn.style.display = visible ? '' : 'none';
122
+ if (divider) divider.style.display = visible ? '' : 'none';
123
+ }
124
+
125
+ document.getElementById('file-view-flat')?.addEventListener('click', () => {
126
+ fileListMode = 'flat';
127
+ localStorage.setItem('devlens-file-view', 'flat');
128
+ document.getElementById('file-view-flat').classList.add('active');
129
+ document.getElementById('file-view-tree').classList.remove('active');
130
+ updateExpandButtonsVisibility();
131
+ renderFileList(currentFiles);
132
+ });
133
+ document.getElementById('file-view-tree')?.addEventListener('click', () => {
134
+ fileListMode = 'tree';
135
+ localStorage.setItem('devlens-file-view', 'tree');
136
+ document.getElementById('file-view-tree').classList.add('active');
137
+ document.getElementById('file-view-flat').classList.remove('active');
138
+ updateExpandButtonsVisibility();
139
+ renderFileList(currentFiles);
140
+ });
141
+
142
+ // Initial visibility
143
+ updateExpandButtonsVisibility();
144
+
145
+ async function loadDiff() {
146
+ try {
147
+ const params = currentFilter !== 'all' ? `?filter=${currentFilter}` : '';
148
+ const res = await fetch(`/api/diff${params}`);
149
+ const data = await res.json();
150
+ diffFiles = splitDiffByFile(data.diff || '');
151
+ currentFiles = data.files || [];
152
+ renderFileList(currentFiles);
153
+ renderAllFiles();
154
+ } catch (err) {
155
+ console.error('Failed to load diff:', err);
156
+ }
157
+ }
158
+
159
+ function splitDiffByFile(rawDiff) {
160
+ if (!rawDiff || !rawDiff.trim()) return [];
161
+
162
+ const files = [];
163
+ const lines = rawDiff.split('\n');
164
+ let current = null;
165
+
166
+ for (const line of lines) {
167
+ if (line.startsWith('diff --git')) {
168
+ if (current) files.push(current);
169
+ const match = line.match(/diff --git a\/(.*) b\/(.*)/);
170
+ current = {
171
+ name: match ? match[2] : 'unknown',
172
+ lines: [line],
173
+ };
174
+ } else if (current) {
175
+ current.lines.push(line);
176
+ }
177
+ }
178
+ if (current) files.push(current);
179
+
180
+ return files.map(f => ({
181
+ name: f.name,
182
+ diff: f.lines.join('\n'),
183
+ }));
184
+ }
185
+
186
+ function renderAllFiles() {
187
+ const container = document.getElementById('diff-output');
188
+
189
+ if (diffFiles.length === 0) {
190
+ container.innerHTML = `
191
+ <div class="empty-state">
192
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3">
193
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
194
+ </svg>
195
+ <p>No changes detected</p>
196
+ <span>Edit files in your project to see diffs here</span>
197
+ </div>`;
198
+ return;
199
+ }
200
+
201
+ const outputFormat = currentViewMode === 'side-by-side' ? 'side-by-side' : 'line-by-line';
202
+
203
+ // First file expanded, rest collapsed
204
+ container.innerHTML = diffFiles.map((file, i) => {
205
+ const diffHtml = Diff2Html.html(file.diff, {
206
+ drawFileList: false,
207
+ matching: 'lines',
208
+ outputFormat: outputFormat,
209
+ colorScheme: 'dark',
210
+ });
211
+
212
+ const shortName = file.name.split('/').pop();
213
+
214
+ return `
215
+ <div class="diff-file-section ${i === 0 ? 'expanded' : ''}" data-file="${file.name}">
216
+ <div class="diff-file-header" onclick="toggleFileSection(this)">
217
+ <svg class="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
218
+ <polyline points="9 18 15 12 9 6"/>
219
+ </svg>
220
+ <span class="diff-file-name">${file.name}</span>
221
+ <span class="diff-file-badge">${shortName}</span>
222
+ </div>
223
+ <div class="diff-file-body">${diffHtml}</div>
224
+ </div>
225
+ `;
226
+ }).join('');
227
+ }
228
+
229
+ // Chevron click — toggle just this file, don't touch others
230
+ function toggleFileSection(headerEl) {
231
+ const section = headerEl.closest('.diff-file-section');
232
+ section.classList.toggle('expanded');
233
+ }
234
+
235
+ var STATUS_LABELS = { modified: 'M', added: 'A', deleted: 'D', untracked: 'U', renamed: 'R' };
236
+
237
+ function renderFileList(files) {
238
+ const list = document.getElementById('file-list-items');
239
+ const countEl = document.getElementById('file-count');
240
+
241
+ if (!files || files.length === 0) {
242
+ list.innerHTML = '<li class="empty-state-inline">No changed files</li>';
243
+ if (countEl) countEl.textContent = '0';
244
+ return;
245
+ }
246
+
247
+ if (countEl) countEl.textContent = files.length;
248
+
249
+ if (fileListMode === 'tree') {
250
+ list.innerHTML = renderTreeView(files);
251
+ } else {
252
+ list.innerHTML = files.map(f => `
253
+ <li title="${f.path}" role="button" tabindex="0" class="file-status-${f.status}">
254
+ <span class="status-badge ${f.status}"></span>
255
+ <span class="file-name">${f.path.split('/').pop()}</span>
256
+ <span class="status-label-tag ${f.status}">${STATUS_LABELS[f.status] || '?'}</span>
257
+ ${f.staged ? '<span class="tag">S</span>' : ''}
258
+ </li>
259
+ `).join('');
260
+ }
261
+ }
262
+
263
+ // Track collapsed folder state across renders
264
+ var collapsedFolders = new Set(JSON.parse(localStorage.getItem('devlens-collapsed-folders') || '[]'));
265
+
266
+ function saveCollapsedFolders() {
267
+ localStorage.setItem('devlens-collapsed-folders', JSON.stringify(Array.from(collapsedFolders)));
268
+ }
269
+
270
+ function toggleFolder(folderPath) {
271
+ if (collapsedFolders.has(folderPath)) {
272
+ collapsedFolders.delete(folderPath);
273
+ } else {
274
+ collapsedFolders.add(folderPath);
275
+ }
276
+ saveCollapsedFolders();
277
+ renderFileList(currentFiles);
278
+ }
279
+
280
+ function renderTreeView(files) {
281
+ const tree = {};
282
+ for (const f of files) {
283
+ const parts = f.path.split('/');
284
+ let node = tree;
285
+ for (let i = 0; i < parts.length - 1; i++) {
286
+ if (!node[parts[i]]) node[parts[i]] = {};
287
+ node = node[parts[i]];
288
+ }
289
+ node[parts[parts.length - 1]] = f;
290
+ }
291
+
292
+ function renderNode(obj, depth, parentPath) {
293
+ let html = '';
294
+ const folders = Object.keys(obj).filter(k => typeof obj[k] === 'object' && !obj[k].path);
295
+ const fileKeys = Object.keys(obj).filter(k => typeof obj[k] === 'object' && obj[k].path);
296
+
297
+ for (const folder of folders.sort()) {
298
+ const folderPath = parentPath ? `${parentPath}/${folder}` : folder;
299
+ const isCollapsed = collapsedFolders.has(folderPath);
300
+ html += `<li class="tree-folder" data-folder="${folderPath}" style="padding-left:${depth * 16}px">
301
+ <svg class="tree-chevron ${isCollapsed ? '' : 'expanded'}" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
302
+ <polyline points="9 18 15 12 9 6"/>
303
+ </svg>
304
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;color:var(--color-text-muted)">
305
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
306
+ </svg>
307
+ <span class="folder-name">${folder}</span>
308
+ </li>`;
309
+ if (!isCollapsed) {
310
+ html += renderNode(obj[folder], depth + 1, folderPath);
311
+ }
312
+ }
313
+
314
+ for (const key of fileKeys.sort()) {
315
+ const f = obj[key];
316
+ html += `<li title="${f.path}" role="button" tabindex="0" style="padding-left:${depth * 16 + 8}px" class="file-status-${f.status}">
317
+ <span class="status-badge ${f.status}"></span>
318
+ <span class="file-name">${key}</span>
319
+ <span class="status-label-tag ${f.status}">${STATUS_LABELS[f.status] || '?'}</span>
320
+ ${f.staged ? '<span class="tag">S</span>' : ''}
321
+ </li>`;
322
+ }
323
+
324
+ return html;
325
+ }
326
+
327
+ return renderNode(tree, 0, '');
328
+ }
329
+
330
+ function handleDiffUpdate(payload) {
331
+ loadDiff();
332
+ }
333
+
334
+ function handleStatusUpdate(payload) {}
335
+
336
+ // Initial load
337
+ loadDiff();
@@ -0,0 +1,194 @@
1
+ // Integrations — Tunnel + Task Managers
2
+
3
+ // ---- Tunnel ----
4
+ async function loadTunnelStatus() {
5
+ try {
6
+ const res = await fetch('/api/integrations/tunnel/status');
7
+ const status = await res.json();
8
+ updateTunnelUI(status);
9
+ } catch {}
10
+ }
11
+
12
+ function updateTunnelUI(status) {
13
+ const badge = document.getElementById('tunnel-badge');
14
+ const activeCard = document.getElementById('tunnel-active');
15
+ const controls = document.getElementById('tunnel-controls');
16
+ const urlEl = document.getElementById('tunnel-url');
17
+
18
+ badge.className = 'tunnel-status-badge ' + status.status;
19
+ badge.textContent = status.status;
20
+
21
+ if (status.status === 'connected' && status.url) {
22
+ activeCard.style.display = '';
23
+ controls.style.display = 'none';
24
+ urlEl.href = status.url;
25
+ urlEl.textContent = status.url;
26
+ } else {
27
+ activeCard.style.display = 'none';
28
+ controls.style.display = '';
29
+ }
30
+ }
31
+
32
+ async function startTunnelAction(provider) {
33
+ const badge = document.getElementById('tunnel-badge');
34
+ badge.className = 'tunnel-status-badge connecting';
35
+ badge.textContent = 'connecting';
36
+
37
+ try {
38
+ const res = await fetch('/api/integrations/tunnel/start', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ provider }),
42
+ });
43
+ const data = await res.json();
44
+ if (data.ok) {
45
+ updateTunnelUI({ status: 'connected', url: data.url });
46
+ showToast(`Tunnel connected: ${data.url}`, 'success');
47
+ } else {
48
+ updateTunnelUI({ status: 'error' });
49
+ showToast(data.error || 'Tunnel failed', 'error');
50
+ }
51
+ } catch (err) {
52
+ updateTunnelUI({ status: 'error' });
53
+ showToast('Failed to start tunnel', 'error');
54
+ }
55
+ }
56
+
57
+ async function stopTunnelAction() {
58
+ try {
59
+ await fetch('/api/integrations/tunnel/stop', { method: 'POST' });
60
+ updateTunnelUI({ status: 'disconnected' });
61
+ showToast('Tunnel disconnected', 'info');
62
+ } catch {
63
+ showToast('Failed to stop tunnel', 'error');
64
+ }
65
+ }
66
+
67
+ function copyTunnelUrl() {
68
+ const url = document.getElementById('tunnel-url').textContent;
69
+ navigator.clipboard.writeText(url).then(() => {
70
+ showToast('URL copied!', 'success');
71
+ });
72
+ }
73
+
74
+ // ---- Task Managers (Jira / Linear) ----
75
+ async function loadIntegrations() {
76
+ const container = document.getElementById('integrations-content');
77
+ try {
78
+ const res = await fetch('/api/integrations/status');
79
+ const status = await res.json();
80
+
81
+ if (!status.jira && !status.linear) {
82
+ container.innerHTML = `
83
+ <div class="integration-card">
84
+ <h4><span class="source-badge jira">Jira</span> Configuration</h4>
85
+ <form id="jira-config-form" class="config-form">
86
+ <div class="form-row">
87
+ <div class="form-group"><label>Base URL<input type="url" id="jira-url" placeholder="https://myorg.atlassian.net"></label></div>
88
+ <div class="form-group"><label>Email<input type="email" id="jira-email" placeholder="you@example.com"></label></div>
89
+ </div>
90
+ <div class="form-row">
91
+ <div class="form-group"><label>API Token<input type="password" id="jira-token"></label></div>
92
+ <div class="form-group"><label>Project Key<input type="text" id="jira-project" placeholder="DEV"></label></div>
93
+ </div>
94
+ <button type="submit" class="btn btn-primary" style="margin-top:var(--sp-2)">Save Jira Config</button>
95
+ </form>
96
+ </div>
97
+ <div class="integration-card">
98
+ <h4><span class="source-badge linear">Linear</span> Configuration</h4>
99
+ <form id="linear-config-form" class="config-form">
100
+ <div class="form-row">
101
+ <div class="form-group"><label>API Key<input type="password" id="linear-key"></label></div>
102
+ <div class="form-group"><label>Team ID (optional)<input type="text" id="linear-team"></label></div>
103
+ </div>
104
+ <button type="submit" class="btn btn-primary" style="margin-top:var(--sp-2)">Save Linear Config</button>
105
+ </form>
106
+ </div>
107
+ `;
108
+
109
+ document.getElementById('jira-config-form')?.addEventListener('submit', async (e) => {
110
+ e.preventDefault();
111
+ await saveConfig({
112
+ jira: {
113
+ baseUrl: document.getElementById('jira-url').value,
114
+ email: document.getElementById('jira-email').value,
115
+ apiToken: document.getElementById('jira-token').value,
116
+ projectKey: document.getElementById('jira-project').value,
117
+ }
118
+ });
119
+ });
120
+
121
+ document.getElementById('linear-config-form')?.addEventListener('submit', async (e) => {
122
+ e.preventDefault();
123
+ await saveConfig({
124
+ linear: {
125
+ apiKey: document.getElementById('linear-key').value,
126
+ teamId: document.getElementById('linear-team').value || undefined,
127
+ }
128
+ });
129
+ });
130
+ } else {
131
+ const issuesRes = await fetch('/api/integrations/all');
132
+ const issues = await issuesRes.json();
133
+ renderExternalTasks(container, issues, status);
134
+ }
135
+ } catch (err) {
136
+ container.innerHTML = '<p class="panel-empty">Failed to load integrations</p>';
137
+ }
138
+ }
139
+
140
+ async function saveConfig(config) {
141
+ try {
142
+ await fetch('/api/integrations/config', {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify(config),
146
+ });
147
+ showToast('Configuration saved', 'success');
148
+ loadIntegrations();
149
+ } catch (err) {
150
+ showToast('Failed to save config', 'error');
151
+ }
152
+ }
153
+
154
+ function renderExternalTasks(container, tasks, status) {
155
+ const sources = [];
156
+ if (status.jira) sources.push('<span class="source-badge jira">Jira</span>');
157
+ if (status.linear) sources.push('<span class="source-badge linear">Linear</span>');
158
+
159
+ container.innerHTML = `
160
+ <div style="display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-4)">
161
+ <span style="color:var(--color-text-secondary)">Connected:</span>
162
+ ${sources.join(' ')}
163
+ <button class="btn btn-ghost btn-sm" onclick="loadIntegrations()" style="margin-left:auto">Refresh</button>
164
+ </div>
165
+ ${tasks.length === 0 ? '<p class="panel-empty">No external tasks found</p>' : ''}
166
+ ${tasks.map(t => `
167
+ <div class="integration-card">
168
+ <h4>
169
+ <span class="source-badge ${t.source}">${t.source}</span>
170
+ <a href="${escapeHtml(t.url)}" target="_blank" style="color:var(--color-primary-hover)">${escapeHtml(t.externalId)}</a>
171
+ — ${escapeHtml(t.title)}
172
+ </h4>
173
+ <p style="color:var(--color-text-secondary);font-size:var(--text-sm)">${escapeHtml(t.description || '').substring(0, 200)}</p>
174
+ <div style="margin-top:var(--sp-2);display:flex;gap:var(--sp-2)">
175
+ <span class="tag">${t.status}</span>
176
+ <span class="tag">${t.priority}</span>
177
+ ${t.assignee ? `<span style="color:var(--color-text-muted);font-size:var(--text-xs)">${escapeHtml(t.assignee)}</span>` : ''}
178
+ </div>
179
+ </div>
180
+ `).join('')}
181
+ `;
182
+ }
183
+
184
+ function escapeHtml(str) {
185
+ const div = document.createElement('div');
186
+ div.textContent = str;
187
+ return div.innerHTML;
188
+ }
189
+
190
+ // Load on tab click
191
+ document.querySelector('[data-tab="integrations"]')?.addEventListener('click', () => {
192
+ loadTunnelStatus();
193
+ loadIntegrations();
194
+ });