@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.
- package/README.md +164 -0
- package/bin/devlens.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +205 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +3 -0
- package/dist/init.js +239 -0
- package/dist/init.js.map +1 -0
- package/dist/routes/diff.d.ts +1 -0
- package/dist/routes/diff.js +39 -0
- package/dist/routes/diff.js.map +1 -0
- package/dist/routes/integrations.d.ts +1 -0
- package/dist/routes/integrations.js +132 -0
- package/dist/routes/integrations.js.map +1 -0
- package/dist/routes/rules.d.ts +1 -0
- package/dist/routes/rules.js +115 -0
- package/dist/routes/rules.js.map +1 -0
- package/dist/routes/tasks.d.ts +4 -0
- package/dist/routes/tasks.js +360 -0
- package/dist/routes/tasks.js.map +1 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +112 -0
- package/dist/server.js.map +1 -0
- package/dist/services/claudeTasks.d.ts +23 -0
- package/dist/services/claudeTasks.js +160 -0
- package/dist/services/claudeTasks.js.map +1 -0
- package/dist/services/config.d.ts +3 -0
- package/dist/services/config.js +25 -0
- package/dist/services/config.js.map +1 -0
- package/dist/services/git.d.ts +8 -0
- package/dist/services/git.js +90 -0
- package/dist/services/git.js.map +1 -0
- package/dist/services/jira.d.ts +11 -0
- package/dist/services/jira.js +52 -0
- package/dist/services/jira.js.map +1 -0
- package/dist/services/linear.d.ts +9 -0
- package/dist/services/linear.js +69 -0
- package/dist/services/linear.js.map +1 -0
- package/dist/services/rules.d.ts +14 -0
- package/dist/services/rules.js +133 -0
- package/dist/services/rules.js.map +1 -0
- package/dist/services/taskStore.d.ts +27 -0
- package/dist/services/taskStore.js +261 -0
- package/dist/services/taskStore.js.map +1 -0
- package/dist/services/tunnel.d.ts +8 -0
- package/dist/services/tunnel.js +152 -0
- package/dist/services/tunnel.js.map +1 -0
- package/dist/services/watcher.d.ts +2 -0
- package/dist/services/watcher.js +30 -0
- package/dist/services/watcher.js.map +1 -0
- package/dist/types/index.d.ts +87 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +53 -0
- package/public/css/style.css +1613 -0
- package/public/index.html +395 -0
- package/public/js/app.js +104 -0
- package/public/js/diff.js +337 -0
- package/public/js/integrations.js +194 -0
- package/public/js/rules.js +174 -0
- 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
|
+
});
|