Feat v0.7.9 workflow independence (#63)
All checks were successful
CI/CD / detect-changes (push) Successful in 20s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / test-go-pg (push) Successful in 3m0s
CI/CD / build-and-deploy (push) Successful in 1m26s
All checks were successful
CI/CD / detect-changes (push) Successful in 20s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / test-go-pg (push) Successful in 3m0s
CI/CD / build-and-deploy (push) Successful in 1m26s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #63.
This commit is contained in:
@@ -1,531 +0,0 @@
|
||||
/* ==========================================
|
||||
Armature — Editor Surface (v0.25.0)
|
||||
==========================================
|
||||
Replaces editor-mode.css for the pane-based editor.
|
||||
Covers: topbar, bootstrap, and component-specific
|
||||
overrides within the editor context.
|
||||
========================================== */
|
||||
|
||||
/* ── Surface Shell ─────────────────────────── */
|
||||
|
||||
.ext-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Topbar ────────────────────────────────── */
|
||||
|
||||
.ext-editor-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-3);
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.ext-editor-topbar-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.ext-editor-topbar-back:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ext-editor-topbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.ext-editor-topbar-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Workspace Selector ────────────────────── */
|
||||
|
||||
.ext-editor-ws-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ext-editor-ws-selector-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.ext-editor-ws-selector-btn:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ext-editor-ws-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: var(--sp-1);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
|
||||
.ext-editor-ws-dropdown.open { display: block; }
|
||||
|
||||
.ext-editor-ws-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ext-editor-ws-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.ext-editor-ws-dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ext-editor-ws-dropdown-item.active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ext-editor-ws-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: var(--sp-1) 0;
|
||||
}
|
||||
|
||||
.ext-editor-ws-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ext-editor-topbar-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
background: var(--purple-dim);
|
||||
padding: 2px var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ext-editor-topbar-branch-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--purple);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
/* ── Body ──────────────────────────────────── */
|
||||
|
||||
.ext-editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Bootstrap (no workspace) ──────────────── */
|
||||
|
||||
.ext-editor-bootstrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ext-editor-bootstrap-card {
|
||||
text-align: center;
|
||||
padding: var(--sp-10);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.ext-editor-bootstrap-input {
|
||||
width: 100%;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
margin-bottom: var(--sp-3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ext-editor-bootstrap-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ext-editor-bootstrap-btn {
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ext-editor-bootstrap-btn:hover { opacity: 0.9; }
|
||||
.ext-editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Workspace list in bootstrap */
|
||||
.ext-editor-bootstrap-ws-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
margin-bottom: var(--sp-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ext-editor-bootstrap-ws-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ext-editor-bootstrap-ws-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ext-editor-bootstrap-ws-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── FileTree overrides (in editor context) ── */
|
||||
|
||||
.ext-editor .file-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: 3px var(--sp-2);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-row.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-arrow {
|
||||
width: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-icon {
|
||||
font-size: 13px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ext-editor .file-tree-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Git status indicators */
|
||||
.ext-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning); }
|
||||
.ext-editor .file-tree-row.git-added .file-tree-name { color: var(--success); }
|
||||
.ext-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3); font-style: italic; }
|
||||
.ext-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger); text-decoration: line-through; }
|
||||
|
||||
/* Context menu */
|
||||
.ext-editor-file-tree-ctx-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-1) 0;
|
||||
min-width: 120px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.ext-editor-file-tree-ctx-item {
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ext-editor-file-tree-ctx-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ── CodeEditor overrides ──────────────────── */
|
||||
|
||||
.ext-editor .code-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
height: 32px;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.ext-editor .code-editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: 0 var(--sp-3);
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--border);
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-tab:hover { background: var(--bg-hover); }
|
||||
.ext-editor .code-editor-tab.active { color: var(--text); background: var(--bg); }
|
||||
.ext-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning); }
|
||||
|
||||
.ext-editor .code-editor-tab-icon { font-size: 12px; }
|
||||
.ext-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
||||
|
||||
.ext-editor .code-editor-tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
margin-left: var(--sp-1);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-tab-close:hover {
|
||||
background: var(--danger-dim);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-welcome {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-cm-wrap {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-cm-wrap .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: 0 var(--sp-3);
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.ext-editor .code-editor-textarea-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: var(--sp-3);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Tabbed assist pane overrides ──────────── */
|
||||
|
||||
.ext-editor .pane-tabbed {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ChatPane in editor tabbed pane */
|
||||
.ext-editor .chat-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Notes in editor pane */
|
||||
.ext-editor .ext-notes-editor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ext-editor .ext-notes-editor-list-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ext-editor .ext-notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Compact notes toolbar for narrow pane */
|
||||
.ext-editor .ext-notes-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-2) var(--sp-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ext-editor .ext-notes-toolbar .sw-btn--sm {
|
||||
font-size: 11px;
|
||||
padding: 3px var(--sp-2);
|
||||
}
|
||||
|
||||
.ext-editor .ext-notes-search-row {
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
}
|
||||
|
||||
.ext-editor .ext-notes-filter-row {
|
||||
padding: 2px var(--sp-2) var(--sp-1);
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.ext-editor .ext-notes-filter-select {
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
// ==========================================
|
||||
// Armature — Editor Package (v0.31.0)
|
||||
// ==========================================
|
||||
// Installable .pkg that provides the code editor surface.
|
||||
// Mounts into #extension-mount (surface-extension template).
|
||||
//
|
||||
// Uses Component.mount() for all sub-components — single source
|
||||
// of truth for DOM structure. No duplicated template partials.
|
||||
//
|
||||
// Dependencies (loaded by base.html):
|
||||
// FileTree, CodeEditor, ChatPane, NotePanel, note-graph, PaneContainer
|
||||
// API, UI, App, sw, esc (ui-primitives)
|
||||
//
|
||||
// Dynamically loaded:
|
||||
// codemirror.bundle.js
|
||||
// ==========================================
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const SURFACE_ID = 'editor';
|
||||
const PREFIX = 'ed';
|
||||
const NOTES_PREFIX = 'edNotes';
|
||||
const STATE_DEBOUNCE_MS = 2000;
|
||||
|
||||
if (window.__SURFACE__ !== SURFACE_ID) return;
|
||||
|
||||
const base = window.__BASE__ || '';
|
||||
|
||||
// ── Dynamic Script Loader ────────────────────
|
||||
|
||||
function _loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const s = document.createElement('script');
|
||||
s.src = base + src;
|
||||
s.type = 'module';
|
||||
s.onload = resolve;
|
||||
s.onerror = () => { resolve(); }; // Non-fatal
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────
|
||||
|
||||
async function _init() {
|
||||
const mount = document.getElementById('extension-mount');
|
||||
if (!mount) return;
|
||||
|
||||
// Hide server-rendered user menu (we mount our own in the topbar)
|
||||
const serverMenu = document.getElementById('userMenuWrap');
|
||||
if (serverMenu) serverMenu.style.display = 'none';
|
||||
|
||||
// Wrap in surface container for CSS scoping
|
||||
const surface = document.createElement('div');
|
||||
surface.className = 'ext-editor';
|
||||
surface.id = 'editorSurface';
|
||||
mount.appendChild(surface);
|
||||
|
||||
// Read workspace ID from query param
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
const wsId = params.get('ws') || '';
|
||||
|
||||
// Load workspace name
|
||||
let wsName = 'Editor';
|
||||
if (wsId && typeof API !== 'undefined') {
|
||||
try {
|
||||
const ws = await API._get('/api/v1/workspaces/' + wsId);
|
||||
wsName = ws?.name || ws?.data?.name || wsName;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Build topbar
|
||||
const topbar = _buildTopbar(wsName);
|
||||
surface.appendChild(topbar);
|
||||
|
||||
// Mount user menu via SDK (flyout drops down from topbar)
|
||||
if (typeof sw !== 'undefined' && sw.userMenu) {
|
||||
sw.userMenu(topbar, { flyout: 'down' });
|
||||
}
|
||||
|
||||
// Build body + bootstrap
|
||||
const body = document.createElement('div');
|
||||
body.className = 'ext-editor-body';
|
||||
body.id = 'editorBody';
|
||||
surface.appendChild(body);
|
||||
|
||||
const bootstrap = _buildBootstrap();
|
||||
surface.appendChild(bootstrap);
|
||||
|
||||
// Load dynamic dependencies (codemirror only — ChatPane, NotePanel, note-graph are platform scripts in base.html)
|
||||
const ver = window.__VERSION__ || '';
|
||||
const verQ = ver ? '?v=' + ver : '';
|
||||
await _loadScript('/vendor/codemirror/codemirror.bundle.js' + verQ);
|
||||
|
||||
_initWsSelector(wsId);
|
||||
|
||||
if (!wsId) {
|
||||
body.style.display = 'none';
|
||||
bootstrap.style.display = '';
|
||||
_loadBootstrapList();
|
||||
_initBootstrapCreate();
|
||||
return;
|
||||
}
|
||||
|
||||
_mountEditor(wsId, wsName);
|
||||
}
|
||||
|
||||
// ── Topbar ──────────────────────────────────
|
||||
|
||||
function _buildTopbar(wsName) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ext-editor-topbar';
|
||||
el.id = 'editorTopbar';
|
||||
el.innerHTML =
|
||||
'<a href="' + esc(base) + '/" class="ext-editor-topbar-back" title="Back to chat">' +
|
||||
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
||||
'Back' +
|
||||
'</a>' +
|
||||
'<div class="ext-editor-topbar-sep"></div>' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' +
|
||||
'<div class="ext-editor-ws-selector" id="editorWsSelector">' +
|
||||
'<button class="ext-editor-ws-selector-btn" id="editorWsSelectorBtn">' +
|
||||
'<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' +
|
||||
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
|
||||
'</button>' +
|
||||
'<div class="ext-editor-ws-dropdown" id="editorWsDropdown">' +
|
||||
'<div id="editorWsList" class="ext-editor-ws-list"></div>' +
|
||||
'<div class="ext-editor-ws-dropdown-divider"></div>' +
|
||||
'<button class="ext-editor-ws-dropdown-item ext-editor-ws-new" id="editorWsNewBtn">' +
|
||||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
||||
'New Workspace' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="ext-editor-topbar-branch" id="editorBranchBadge" style="display:none;">' +
|
||||
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' +
|
||||
'<span id="editorBranchName" class="ext-editor-topbar-branch-text">main</span>' +
|
||||
'</div>' +
|
||||
'<div style="flex:1;"></div>' +
|
||||
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>' +
|
||||
'</button>';
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Bootstrap (no workspace) ────────────────
|
||||
|
||||
function _buildBootstrap() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ext-editor-bootstrap';
|
||||
el.id = 'editorBootstrap';
|
||||
el.style.display = 'none';
|
||||
el.innerHTML =
|
||||
'<div class="ext-editor-bootstrap-card">' +
|
||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' +
|
||||
'<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"/>' +
|
||||
'</svg>' +
|
||||
'<h3 style="margin:0 0 16px;font-size:16px;">Open a Workspace</h3>' +
|
||||
'<div id="editorBootstrapList" style="margin-bottom:16px;">' +
|
||||
'<div style="font-size:12px;color:var(--text-3);">Loading workspaces\u2026</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">' +
|
||||
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
|
||||
'<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' +
|
||||
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
|
||||
'</div>' +
|
||||
'<input type="text" id="editorBootstrapName" class="ext-editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
|
||||
'<button id="editorBootstrapBtn" class="ext-editor-bootstrap-btn">Create Workspace</button>' +
|
||||
'</div>';
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Workspace Selector ──────────────────────
|
||||
|
||||
function _initWsSelector(currentWsId) {
|
||||
const btn = document.getElementById('editorWsSelectorBtn');
|
||||
const dropdown = document.getElementById('editorWsDropdown');
|
||||
if (!btn || !dropdown) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (dropdown.classList.toggle('open')) _loadWsDropdown(currentWsId);
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
|
||||
});
|
||||
document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
|
||||
dropdown.classList.remove('open');
|
||||
const name = window.prompt('Workspace name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
const userId = sw.user?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
if (newId) window.location.href = base + '/s/editor?ws=' + newId;
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadWsDropdown(currentWsId) {
|
||||
const listEl = document.getElementById('editorWsList');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading\u2026</div>';
|
||||
try {
|
||||
const resp = await API._get('/api/v1/workspaces');
|
||||
const workspaces = resp.data || resp || [];
|
||||
listEl.innerHTML = '';
|
||||
if (!workspaces.length) {
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
|
||||
return;
|
||||
}
|
||||
workspaces.forEach(ws => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'ext-editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
|
||||
item.textContent = ws.name || ws.id?.slice(0, 8);
|
||||
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bootstrap ────────────────────────────────
|
||||
|
||||
async function _loadBootstrapList() {
|
||||
const listEl = document.getElementById('editorBootstrapList');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await API._get('/api/v1/workspaces');
|
||||
const workspaces = resp.data || resp || [];
|
||||
if (!workspaces.length) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
workspaces.forEach(ws => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'ext-editor-bootstrap-ws-item';
|
||||
item.innerHTML =
|
||||
'<span class="ext-editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
|
||||
'<span class="ext-editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
|
||||
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _initBootstrapCreate() {
|
||||
const btn = document.getElementById('editorBootstrapBtn');
|
||||
const input = document.getElementById('editorBootstrapName');
|
||||
if (!btn || !input) return;
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = input.value.trim() || 'workspace';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating\u2026';
|
||||
try {
|
||||
const userId = sw.user?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
if (!newId) throw new Error('No workspace ID returned');
|
||||
window.location.href = base + '/s/editor?ws=' + newId;
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Workspace';
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mount Pane Layout ───────────────────────
|
||||
|
||||
function _mountEditor(wsId, wsName) {
|
||||
const body = document.getElementById('editorBody');
|
||||
if (!body) return;
|
||||
|
||||
// ── Layout via SDK ──
|
||||
const layout = sw.layout(body, 'editor', { workspaceId: wsId });
|
||||
if (!layout) return;
|
||||
|
||||
const filesPaneEl = layout._panes.get('files')?.el;
|
||||
const editorPaneEl = layout._panes.get('editor')?.el;
|
||||
const assistPaneInfo = layout._panes.get('assist');
|
||||
|
||||
// ── FileTree via SDK ──
|
||||
let fileTree;
|
||||
if (filesPaneEl) {
|
||||
fileTree = sw.fileTree(filesPaneEl, {
|
||||
id: PREFIX, workspaceId: wsId,
|
||||
onSelect: (path) => { codeEditor?.openFile(path); _saveState(wsId, codeEditor); },
|
||||
onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
|
||||
onNewFile: () => _createNewFile(wsId, fileTree),
|
||||
});
|
||||
layout._panes.get('files').component = fileTree;
|
||||
}
|
||||
|
||||
// ── CodeEditor via SDK ──
|
||||
let codeEditor;
|
||||
if (editorPaneEl) {
|
||||
codeEditor = sw.codeEditor(editorPaneEl, {
|
||||
id: PREFIX, workspaceId: wsId,
|
||||
onSave: () => fileTree?.refresh(),
|
||||
onActivate: (path) => { fileTree?.setActiveFile(path); _saveState(wsId, codeEditor); },
|
||||
});
|
||||
layout._panes.get('editor').component = codeEditor;
|
||||
}
|
||||
|
||||
// ── Assist pane (tabbed: chat + notes) via SDK ──
|
||||
if (assistPaneInfo?.tabs) {
|
||||
// Chat tab — standalone mode handles everything (streaming, model selector, history)
|
||||
const chatPanel = assistPaneInfo.getTabPanel('chat');
|
||||
if (chatPanel) {
|
||||
const chatPane = sw.chat(chatPanel, {
|
||||
id: PREFIX,
|
||||
standalone: true,
|
||||
getContext: () => _getFileContext(codeEditor),
|
||||
});
|
||||
if (chatPane) {
|
||||
const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
|
||||
if (chatTab) chatTab.instance = chatPane;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes tab
|
||||
const notesPanel = assistPaneInfo.getTabPanel('notes');
|
||||
if (notesPanel) {
|
||||
const notePanel = sw.notes(notesPanel, { projectId: null });
|
||||
if (notePanel) {
|
||||
const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
|
||||
if (notesTab) {
|
||||
notesTab.instance = notePanel;
|
||||
const _loadNotes = () => {
|
||||
if (!notesTab._loaded) {
|
||||
notesTab._loaded = true;
|
||||
notePanel.loadNotesList();
|
||||
notePanel.loadNoteFolders();
|
||||
}
|
||||
};
|
||||
notesTab.btn.addEventListener('click', _loadNotes);
|
||||
if (notesTab._activated) _loadNotes();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Git branch
|
||||
_refreshGitBranch(wsId, codeEditor);
|
||||
|
||||
// Toolbar
|
||||
document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
|
||||
fileTree?.refresh();
|
||||
_refreshGitBranch(wsId, codeEditor);
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fileTree?.refresh();
|
||||
_restoreState(wsId, codeEditor);
|
||||
|
||||
console.log('[EditorPkg] Mounted for workspace', wsId);
|
||||
}
|
||||
|
||||
// ── File Operations ─────────────────────────
|
||||
|
||||
async function _deleteFile(wsId, path, fileTree, codeEditor) {
|
||||
const ok = typeof sw !== 'undefined' && sw.confirm
|
||||
? await sw.confirm('Delete ' + path + '?', { destructive: true })
|
||||
: window.confirm('Delete ' + path + '?');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.deleteWorkspaceFile(wsId, path);
|
||||
if (codeEditor?.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
|
||||
fileTree?.refresh();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _createNewFile(wsId, fileTree) {
|
||||
const name = window.prompt('File name (e.g. src/main.go):');
|
||||
if (!name) return;
|
||||
try {
|
||||
await API.writeWorkspaceFile(wsId, name.trim(), '');
|
||||
fileTree?.refresh();
|
||||
if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _refreshGitBranch(wsId, codeEditor) {
|
||||
try {
|
||||
const resp = await API.getWorkspaceGitBranches(wsId);
|
||||
const branch = resp.current || null;
|
||||
if (branch) {
|
||||
const badge = document.getElementById('editorBranchBadge');
|
||||
const name = document.getElementById('editorBranchName');
|
||||
if (badge) badge.style.display = '';
|
||||
if (name) name.textContent = branch;
|
||||
}
|
||||
if (codeEditor) codeEditor.setBranch(branch);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── File Context (editor-specific, passed to ChatPane via getContext) ──
|
||||
|
||||
function _getFileContext(editor) {
|
||||
if (!editor) return null;
|
||||
try {
|
||||
const openFiles = editor.getOpenFiles();
|
||||
if (!openFiles.length) return null;
|
||||
const activeTab = document.querySelector('.code-editor-tab.active');
|
||||
const path = activeTab?.dataset?.path || openFiles[0];
|
||||
const inst = CodeEditor._instances?.get(PREFIX);
|
||||
if (inst?._files) {
|
||||
const file = inst._files.get(path);
|
||||
if (file?.view) return { path, content: file.view.state.doc.toString().slice(0, 4000) };
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── State Persistence (localStorage) ─────────
|
||||
|
||||
const STATE_KEY_PREFIX = 'sb:editor:state:';
|
||||
let _stateSaveTimer = null;
|
||||
|
||||
function _stateKey(wsId) {
|
||||
return STATE_KEY_PREFIX + (sw?.user?.id || '') + ':' + wsId;
|
||||
}
|
||||
|
||||
function _restoreState(wsId, codeEditor) {
|
||||
if (!wsId) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(_stateKey(wsId));
|
||||
if (!raw) return;
|
||||
const state = JSON.parse(raw);
|
||||
if (state.open_tabs && Array.isArray(state.open_tabs)) {
|
||||
for (const path of state.open_tabs) codeEditor.openFile(path);
|
||||
if (state.active_tab) codeEditor.activateFile(state.active_tab);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _saveState(wsId, codeEditor) {
|
||||
if (_stateSaveTimer) clearTimeout(_stateSaveTimer);
|
||||
_stateSaveTimer = setTimeout(() => {
|
||||
if (!wsId) return;
|
||||
try {
|
||||
const openFiles = codeEditor.getOpenFiles();
|
||||
const activeTab = document.querySelector('.code-editor-tab.active');
|
||||
localStorage.setItem(_stateKey(wsId), JSON.stringify({
|
||||
open_tabs: openFiles,
|
||||
active_tab: activeTab?.dataset?.path || '',
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
} catch (_) {}
|
||||
}, STATE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
})();
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"id": "editor",
|
||||
"title": "Editor",
|
||||
"type": "full",
|
||||
"version": "0.31.0",
|
||||
"tier": "browser",
|
||||
"author": "Armature",
|
||||
"icon": "✏️",
|
||||
"description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
|
||||
"requires": ["legacy-sdk"],
|
||||
"route": "/s/editor",
|
||||
"layout": "editor",
|
||||
"permissions": [],
|
||||
"settings": [
|
||||
{ "key": "font_size", "label": "Font Size", "type": "number", "default": 13 },
|
||||
{ "key": "tab_size", "label": "Tab Size", "type": "number", "default": 4 },
|
||||
{ "key": "word_wrap", "label": "Word Wrap", "type": "boolean", "default": false }
|
||||
]
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
/* Git Board — Surface Styles */
|
||||
|
||||
.ext-git-board-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────── */
|
||||
|
||||
.ext-git-board-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ext-git-board-header__left,
|
||||
.ext-git-board-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ext-git-board-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ext-git-board-repo-picker {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
padding: 5px var(--sp-2);
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
/* ── Connection Setup ─────────────────────── */
|
||||
|
||||
.ext-git-board-setup {
|
||||
max-width: 480px;
|
||||
margin: 60px auto;
|
||||
text-align: center;
|
||||
padding: 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.ext-git-board-setup h2 {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
margin: 0 0 var(--sp-2);
|
||||
}
|
||||
|
||||
.ext-git-board-setup p {
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 var(--sp-5);
|
||||
}
|
||||
|
||||
|
||||
.ext-git-board-setup__hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Kanban Board ────────────────────────── */
|
||||
|
||||
.ext-git-board-board {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.ext-git-board-column {
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ext-git-board-column__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ext-git-board-column__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.ext-git-board-column__count {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.ext-git-board-column__cards {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────── */
|
||||
|
||||
.ext-git-board-card {
|
||||
display: block;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-3);
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ext-git-board-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ext-git-board-card--pr {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.ext-git-board-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.ext-git-board-card__number {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.ext-git-board-card__assignee {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ext-git-board-card__branch {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.ext-git-board-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ext-git-board-card__labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.ext-git-board-card__labels .ext-git-board-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px var(--sp-2);
|
||||
}
|
||||
|
||||
.ext-git-board-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--sp-2);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── DnD States ─────────────────────────── */
|
||||
|
||||
.ext-git-board-card[draggable="true"] {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.ext-git-board-card[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.ext-git-board-column--dragover {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
|
||||
}
|
||||
.ext-git-board-column--dragover .ext-git-board-column__header {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────── */
|
||||
|
||||
.ext-git-board-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Issue Detail Modal ─────────────────── */
|
||||
|
||||
.ext-git-board-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: var(--sp-10) var(--sp-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ext-git-board-modal {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-height: calc(100vh - 80px);
|
||||
max-height: calc(100dvh - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.ext-git-board-modal__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ext-git-board-modal__title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ext-git-board-modal__title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ext-git-board-modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 2px var(--sp-2);
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ext-git-board-modal__close:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ext-git-board-modal__body {
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ext-git-board-modal__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--sp-3);
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.ext-git-board-modal__date {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.ext-git-board-modal__extlink {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ext-git-board-modal__extlink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ext-git-board-modal__description {
|
||||
margin-bottom: var(--sp-5);
|
||||
padding-bottom: var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ext-git-board-modal__body-text {
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ext-git-board-modal__empty {
|
||||
color: var(--text-3);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ext-git-board-modal__section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0 0 var(--sp-3);
|
||||
}
|
||||
|
||||
/* ── Comments ────────────────────────────── */
|
||||
|
||||
.ext-git-board-comment {
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ext-git-board-comment:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ext-git-board-comment__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-1);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ext-git-board-comment__header strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ext-git-board-comment__date {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ext-git-board-comment__body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Add Comment ─────────────────────────── */
|
||||
|
||||
.ext-git-board-modal__add-comment {
|
||||
margin-top: var(--sp-4);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ext-git-board-modal__textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ext-git-board-modal__textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ext-git-board-modal__actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Badge variants ──────────────────────── */
|
||||
|
||||
.ext-git-board-badge--green {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
.ext-git-board-badge--muted {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* git-board danger button override — uses kernel .sw-btn--danger */
|
||||
@@ -1,491 +0,0 @@
|
||||
/**
|
||||
* Git Board — Surface Entry Point
|
||||
*
|
||||
* Kanban board for Gitea issues and PRs.
|
||||
* Calls Starlark backend via /s/git-board/api/*.
|
||||
* Authentication via gitea-client library connections.
|
||||
*
|
||||
* Features: DnD between columns, issue detail modal with comments.
|
||||
*/
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
var mount = document.getElementById('extension-mount');
|
||||
if (!mount) return;
|
||||
|
||||
var base = window.__BASE__ || '';
|
||||
var ver = window.__VERSION__ || '0';
|
||||
var slug = 'git-board';
|
||||
|
||||
// ── Boot SDK ───────────────────────────────
|
||||
try {
|
||||
if (!window.preact) {
|
||||
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
||||
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
||||
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
||||
window.preact = { h, render };
|
||||
window.hooks = hooksModule;
|
||||
window.html = htmModule.default.bind(h);
|
||||
}
|
||||
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
||||
await sdk.boot();
|
||||
} catch (e) {
|
||||
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var { html } = window;
|
||||
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
|
||||
var { render } = preact;
|
||||
|
||||
var API = base + '/s/' + slug + '/api';
|
||||
|
||||
async function api(path, opts) {
|
||||
var token = sw.auth._getToken();
|
||||
var fetchOpts = { headers: { 'Authorization': 'Bearer ' + token } };
|
||||
if (opts && opts.method) fetchOpts.method = opts.method;
|
||||
if (opts && opts.body) {
|
||||
fetchOpts.body = JSON.stringify(opts.body);
|
||||
fetchOpts.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
var resp = await fetch(API + path, fetchOpts);
|
||||
var text = await resp.text();
|
||||
try { return JSON.parse(text); } catch (_) { return { error: text }; }
|
||||
}
|
||||
|
||||
// ── App ────────────────────────────────────
|
||||
|
||||
function App() {
|
||||
var [owner, setOwner] = useState('');
|
||||
var [repo, setRepo] = useState('');
|
||||
var [repos, setRepos] = useState([]);
|
||||
var [board, setBoard] = useState(null);
|
||||
var [loading, setLoading] = useState(false);
|
||||
var [needsConn, setNeedsConn] = useState(false);
|
||||
var [modal, setModal] = useState(null); // {owner, repo, number}
|
||||
var menuRef = useRef(null);
|
||||
|
||||
useEffect(function () {
|
||||
if (menuRef.current && sw.userMenu) {
|
||||
sw.userMenu(menuRef.current, { placement: 'down-left' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(function () {
|
||||
api('/repos').then(function (d) {
|
||||
if (d.error) {
|
||||
if (d.error.indexOf('no gitea connection configured') !== -1) {
|
||||
setNeedsConn(true);
|
||||
} else { sw.toast(d.error, 'error'); }
|
||||
return;
|
||||
}
|
||||
setRepos(d.data || []);
|
||||
if (d.data && d.data.length > 0 && !owner) {
|
||||
setOwner(d.data[0].owner);
|
||||
setRepo(d.data[0].name);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
var loadBoard = useCallback(function () {
|
||||
if (!owner || !repo) return;
|
||||
setLoading(true);
|
||||
api('/board?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo))
|
||||
.then(function (d) {
|
||||
if (d.error) {
|
||||
sw.toast(d.error, 'error');
|
||||
if (d.error.indexOf('no gitea connection configured') !== -1) setNeedsConn(true);
|
||||
} else {
|
||||
setBoard(d);
|
||||
setNeedsConn(false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, [owner, repo]);
|
||||
|
||||
useEffect(function () { loadBoard(); }, [loadBoard]);
|
||||
|
||||
var onDrop = useCallback(function (issueNumber, targetCol) {
|
||||
if (!board) return;
|
||||
var issue = (board.issues || []).find(function (i) { return i.number === issueNumber; });
|
||||
if (!issue) return;
|
||||
|
||||
var patch = {};
|
||||
if (targetCol === 'open') {
|
||||
// Move to Open: unassign + reopen
|
||||
if (issue.state === 'closed') patch.state = 'open';
|
||||
// Note: Gitea PATCH /issues doesn't support clearing assignee via empty string,
|
||||
// but we do our best — the board will re-split on refresh.
|
||||
} else if (targetCol === 'in_progress') {
|
||||
// Move to In Progress: assign to current user + ensure open
|
||||
if (issue.state === 'closed') patch.state = 'open';
|
||||
// Gitea needs assignees array — not supported by our simple update_issue.
|
||||
// For now, just reopen. User assigns via modal.
|
||||
if (issue.state === 'closed') patch.state = 'open';
|
||||
} else if (targetCol === 'done') {
|
||||
patch.state = 'closed';
|
||||
}
|
||||
|
||||
if (!patch.state) return; // no meaningful change
|
||||
|
||||
// Optimistic update
|
||||
var newIssues = (board.issues || []).map(function (i) {
|
||||
if (i.number !== issueNumber) return i;
|
||||
var copy = {};
|
||||
for (var k in i) copy[k] = i[k];
|
||||
if (patch.state) copy.state = patch.state;
|
||||
return copy;
|
||||
});
|
||||
setBoard({ issues: newIssues, pull_requests: board.pull_requests || [] });
|
||||
|
||||
api('/issue/' + owner + '/' + repo + '/' + issueNumber, {
|
||||
method: 'POST', body: patch
|
||||
}).then(function (d) {
|
||||
if (d.error) {
|
||||
sw.toast('Update failed: ' + d.error, 'error');
|
||||
loadBoard(); // revert
|
||||
}
|
||||
});
|
||||
}, [board, owner, repo, loadBoard]);
|
||||
|
||||
var openModal = useCallback(function (number) {
|
||||
setModal({ owner: owner, repo: repo, number: number });
|
||||
}, [owner, repo]);
|
||||
|
||||
var closeModal = useCallback(function (changed) {
|
||||
setModal(null);
|
||||
if (changed) loadBoard();
|
||||
}, [loadBoard]);
|
||||
|
||||
return html`
|
||||
<div class="user-menu-container" ref=${menuRef}></div>
|
||||
${needsConn ? html`<${ConnectionSetup} />` : html`
|
||||
<div class="ext-git-board-shell">
|
||||
<header class="ext-git-board-header">
|
||||
<div class="ext-git-board-header__left">
|
||||
<h1 class="ext-git-board-title">Git Board</h1>
|
||||
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
||||
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
||||
</div>
|
||||
<div class="ext-git-board-header__right">
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${loadBoard} disabled=${loading}>
|
||||
${loading ? '↻' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
|
||||
<div class="ext-git-board-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`}
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Connection Setup ─────────────────────────
|
||||
|
||||
function ConnectionSetup() {
|
||||
return html`
|
||||
<div class="ext-git-board-shell">
|
||||
<header class="ext-git-board-header">
|
||||
<div class="ext-git-board-header__left"><h1 class="ext-git-board-title">Git Board</h1></div>
|
||||
</header>
|
||||
<div class="ext-git-board-setup">
|
||||
<h2>Connect to Gitea</h2>
|
||||
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
|
||||
<p>Ask your admin to add a <strong>Gitea</strong> connection in
|
||||
<strong>Admin → Connections</strong>, or add a personal one in
|
||||
<strong>Settings → Connections</strong>.</p>
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||
onClick=${function () { window.location.href = base + '/settings'; }}>
|
||||
Open Settings
|
||||
</button>
|
||||
<p class="ext-git-board-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Repo Picker ────────────────────────────
|
||||
|
||||
function RepoPicker({ repos, owner, repo, onSelect }) {
|
||||
var current = owner + '/' + repo;
|
||||
return html`
|
||||
<select class="ext-git-board-repo-picker" value=${current}
|
||||
onChange=${function (e) {
|
||||
var parts = e.target.value.split('/');
|
||||
onSelect(parts[0], parts.slice(1).join('/'));
|
||||
}}>
|
||||
${repos.map(function (r) {
|
||||
return html`<option key=${r.full_name} value=${r.full_name}>${r.full_name}</option>`;
|
||||
})}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Kanban Board with DnD ─────────────────
|
||||
|
||||
function Board({ data, onDrop, onCardClick }) {
|
||||
var issues = data.issues || [];
|
||||
var prs = data.pull_requests || [];
|
||||
|
||||
var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; });
|
||||
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
||||
|
||||
return html`
|
||||
<div class="ext-git-board-board">
|
||||
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
|
||||
${openUnassigned.map(function (i) {
|
||||
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
||||
})}
|
||||
<//>
|
||||
<${Column} id="in_progress" title="In Progress" count=${inProgress.length} onDrop=${onDrop}>
|
||||
${inProgress.map(function (i) {
|
||||
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
||||
})}
|
||||
<//>
|
||||
<${Column} id="done" title="Done" count=${0} onDrop=${onDrop}>
|
||||
<//>
|
||||
<${Column} id="prs" title="Pull Requests" count=${prs.length}>
|
||||
${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
|
||||
<//>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function Column({ id, title, count, children, onDrop }) {
|
||||
var [over, setOver] = useState(false);
|
||||
|
||||
var handlers = onDrop ? {
|
||||
onDragOver: function (e) { e.preventDefault(); setOver(true); },
|
||||
onDragLeave: function () { setOver(false); },
|
||||
onDrop: function (e) {
|
||||
e.preventDefault();
|
||||
setOver(false);
|
||||
var num = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
if (num && onDrop) onDrop(num, id);
|
||||
}
|
||||
} : {};
|
||||
|
||||
return html`
|
||||
<div class="ext-git-board-column ${over ? 'ext-git-board-column--dragover' : ''}" ...${handlers}>
|
||||
<div class="ext-git-board-column__header">
|
||||
<span class="ext-git-board-column__title">${title}</span>
|
||||
<span class="ext-git-board-column__count">${count || 0}</span>
|
||||
</div>
|
||||
<div class="ext-git-board-column__cards">${children}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function IssueCard({ issue, onClick }) {
|
||||
return html`
|
||||
<div class="ext-git-board-card" draggable="true"
|
||||
onDragStart=${function (e) {
|
||||
e.dataTransfer.setData('text/plain', String(issue.number));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick=${function (e) {
|
||||
e.preventDefault();
|
||||
if (onClick) onClick(issue.number);
|
||||
}}>
|
||||
<div class="ext-git-board-card__header">
|
||||
<span class="ext-git-board-card__number">#${issue.number}</span>
|
||||
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||
</div>
|
||||
<div class="ext-git-board-card__title">${esc(issue.title)}</div>
|
||||
<div class="ext-git-board-card__labels">
|
||||
${(issue.labels || []).map(function (l) {
|
||||
return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function PRCard({ pr }) {
|
||||
return html`
|
||||
<a class="ext-git-board-card ext-git-board-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
||||
<div class="ext-git-board-card__header">
|
||||
<span class="ext-git-board-card__number">#${pr.number}</span>
|
||||
<span class="ext-git-board-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
||||
</div>
|
||||
<div class="ext-git-board-card__title">${esc(pr.title)}</div>
|
||||
<div class="ext-git-board-card__meta">
|
||||
<span>@${esc(pr.user)}</span>
|
||||
<span>${timeAgo(pr.created_at)}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Issue Detail Modal ────────────────────
|
||||
|
||||
function IssueModal({ owner, repo, number, onClose }) {
|
||||
var [issue, setIssue] = useState(null);
|
||||
var [loading, setLoading] = useState(true);
|
||||
var [comment, setComment] = useState('');
|
||||
var [posting, setPosting] = useState(false);
|
||||
var [changed, setChanged] = useState(false);
|
||||
var bodyRef = useRef(null);
|
||||
|
||||
useEffect(function () {
|
||||
setLoading(true);
|
||||
api('/issue/' + owner + '/' + repo + '/' + number).then(function (d) {
|
||||
if (d.error) { sw.toast(d.error, 'error'); onClose(false); return; }
|
||||
setIssue(d);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [owner, repo, number]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(function () {
|
||||
var handler = function (e) { if (e.key === 'Escape') onClose(changed); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return function () { document.removeEventListener('keydown', handler); };
|
||||
}, [changed]);
|
||||
|
||||
var postComment = useCallback(function () {
|
||||
if (!comment.trim() || posting) return;
|
||||
setPosting(true);
|
||||
api('/issue/' + owner + '/' + repo + '/' + number + '/comment', {
|
||||
method: 'POST', body: { body: comment.trim() }
|
||||
}).then(function (d) {
|
||||
if (d.error) { sw.toast(d.error, 'error'); }
|
||||
else {
|
||||
// Append to local comments
|
||||
setIssue(function (prev) {
|
||||
if (!prev) return prev;
|
||||
var copy = {};
|
||||
for (var k in prev) copy[k] = prev[k];
|
||||
copy.comments = (prev.comments || []).concat([d]);
|
||||
return copy;
|
||||
});
|
||||
setComment('');
|
||||
setChanged(true);
|
||||
}
|
||||
setPosting(false);
|
||||
});
|
||||
}, [comment, posting, owner, repo, number]);
|
||||
|
||||
var toggleState = useCallback(function () {
|
||||
if (!issue) return;
|
||||
var newState = issue.state === 'open' ? 'closed' : 'open';
|
||||
api('/issue/' + owner + '/' + repo + '/' + number, {
|
||||
method: 'POST', body: { state: newState }
|
||||
}).then(function (d) {
|
||||
if (d.error) { sw.toast(d.error, 'error'); return; }
|
||||
setIssue(function (prev) {
|
||||
if (!prev) return prev;
|
||||
var copy = {};
|
||||
for (var k in prev) copy[k] = prev[k];
|
||||
copy.state = newState;
|
||||
return copy;
|
||||
});
|
||||
setChanged(true);
|
||||
});
|
||||
}, [issue, owner, repo, number]);
|
||||
|
||||
return html`
|
||||
<div class="ext-git-board-modal-overlay" onClick=${function (e) {
|
||||
if (e.target.classList.contains('ext-git-board-modal-overlay')) onClose(changed);
|
||||
}}>
|
||||
<div class="ext-git-board-modal">
|
||||
<div class="ext-git-board-modal__header">
|
||||
<div class="ext-git-board-modal__title-row">
|
||||
${loading ? 'Loading…' : html`
|
||||
<span class="ext-git-board-card__number">#${number}</span>
|
||||
<h2 class="ext-git-board-modal__title">${esc(issue && issue.title)}</h2>
|
||||
`}
|
||||
</div>
|
||||
<button class="ext-git-board-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
|
||||
</div>
|
||||
|
||||
${!loading && issue && html`
|
||||
<div class="ext-git-board-modal__body" ref=${bodyRef}>
|
||||
<div class="ext-git-board-modal__meta">
|
||||
<span class="ext-git-board-badge ${issue.state === 'open' ? 'ext-git-board-badge--green' : 'ext-git-board-badge--muted'}">${issue.state}</span>
|
||||
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||
${issue.created_at && html`<span class="ext-git-board-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
|
||||
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
|
||||
class="ext-git-board-modal__extlink">Open in Gitea ↗</a>
|
||||
</div>
|
||||
|
||||
${issue.labels && issue.labels.length > 0 && html`
|
||||
<div class="ext-git-board-card__labels" style="margin-bottom:12px;">
|
||||
${issue.labels.map(function (l) { return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`; })}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="ext-git-board-modal__description">
|
||||
${issue.body ? html`<pre class="ext-git-board-modal__body-text">${esc(issue.body)}</pre>`
|
||||
: html`<p class="ext-git-board-modal__empty">No description.</p>`}
|
||||
</div>
|
||||
|
||||
<div class="ext-git-board-modal__comments">
|
||||
<h3 class="ext-git-board-modal__section-title">Comments (${(issue.comments || []).length})</h3>
|
||||
${(issue.comments || []).length === 0 && html`
|
||||
<p class="ext-git-board-modal__empty">No comments yet.</p>
|
||||
`}
|
||||
${(issue.comments || []).map(function (c, i) {
|
||||
return html`
|
||||
<div class="ext-git-board-comment" key=${i}>
|
||||
<div class="ext-git-board-comment__header">
|
||||
<strong>@${esc(c.user)}</strong>
|
||||
<span class="ext-git-board-comment__date">${timeAgo(c.created_at)}</span>
|
||||
</div>
|
||||
<div class="ext-git-board-comment__body">${esc(c.body)}</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="ext-git-board-modal__add-comment">
|
||||
<textarea class="ext-git-board-modal__textarea" rows="3"
|
||||
placeholder="Add a comment…"
|
||||
value=${comment}
|
||||
onInput=${function (e) { setComment(e.target.value); }}
|
||||
onKeyDown=${function (e) {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
|
||||
}} />
|
||||
<div class="ext-git-board-modal__actions">
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm" disabled=${posting || !comment.trim()}
|
||||
onClick=${postComment}>
|
||||
${posting ? 'Posting…' : 'Comment'}
|
||||
</button>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm ${issue.state === 'open' ? 'sw-btn sw-btn--danger sw-btn--md' : 'sw-btn sw-btn--secondary sw-btn--md'}"
|
||||
onClick=${toggleState}>
|
||||
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Utilities ──────────────────────────────
|
||||
|
||||
function esc(s) {
|
||||
var el = document.createElement('span');
|
||||
el.textContent = String(s || '');
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
var sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return 'now';
|
||||
var min = Math.floor(sec / 60);
|
||||
if (min < 60) return min + 'm';
|
||||
var hr = Math.floor(min / 60);
|
||||
if (hr < 24) return hr + 'h';
|
||||
return Math.floor(hr / 24) + 'd';
|
||||
}
|
||||
|
||||
// ── Mount ──────────────────────────────────
|
||||
render(html`<${App} />`, mount);
|
||||
console.log('[git-board] Surface mounted');
|
||||
})();
|
||||
@@ -1,114 +0,0 @@
|
||||
{
|
||||
"id": "git-board",
|
||||
"title": "Git Board",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"route": "/s/git-board",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.2.0",
|
||||
"icon": "🔀",
|
||||
"description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
|
||||
"author": "armature",
|
||||
|
||||
"permissions": ["connections.read"],
|
||||
|
||||
"dependencies": {
|
||||
"gitea-client": ">=1.0.0"
|
||||
},
|
||||
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/repos"},
|
||||
{"method": "GET", "path": "/board"},
|
||||
{"method": "GET", "path": "/issue/*"},
|
||||
{"method": "POST", "path": "/issue/*"},
|
||||
{"method": "GET", "path": "/ci/*"}
|
||||
],
|
||||
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_issues",
|
||||
"description": "List issues for a repository. Returns titles, numbers, labels, assignees, and state.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true, "description": "Repository owner or org"},
|
||||
"repo": {"type": "string", "required": true, "description": "Repository name"},
|
||||
"state": {"type": "string", "required": false, "description": "Filter: open (default), closed, all"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_issue",
|
||||
"description": "Get full details for a single issue including body and comments.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"number": {"type": "number", "required": true, "description": "Issue number"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a new issue in a repository.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"title": {"type": "string", "required": true},
|
||||
"body": {"type": "string", "required": false},
|
||||
"labels": {"type": "string", "required": false, "description": "Comma-separated label names"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_issue",
|
||||
"description": "Update an existing issue (title, body, state, labels).",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"number": {"type": "number", "required": true},
|
||||
"title": {"type": "string", "required": false},
|
||||
"body": {"type": "string", "required": false},
|
||||
"state": {"type": "string", "required": false, "description": "open or closed"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "add_comment",
|
||||
"description": "Add a comment to an issue or pull request.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"number": {"type": "number", "required": true},
|
||||
"body": {"type": "string", "required": true}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_pull_requests",
|
||||
"description": "List pull requests for a repository with status, branch, and review state.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"state": {"type": "string", "required": false, "description": "open (default), closed, all"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_ci_status",
|
||||
"description": "Get CI/CD job status for a commit or branch reference.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"ref": {"type": "string", "required": true, "description": "Branch name, tag, or commit SHA"}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"settings": {
|
||||
"default_owner": {
|
||||
"type": "string",
|
||||
"label": "Default Owner/Org",
|
||||
"description": "Default repository owner for the board view.",
|
||||
"default": ""
|
||||
},
|
||||
"default_repo": {
|
||||
"type": "string",
|
||||
"label": "Default Repository",
|
||||
"description": "Default repository name for the board view.",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
# Git Board — Starlark Backend (v0.2.0)
|
||||
#
|
||||
# Library consumer — delegates all Gitea API work to gitea-client.
|
||||
#
|
||||
# Entry points:
|
||||
# on_request(req) → surface API routes
|
||||
# on_tool_call(tool_name, params) → LLM tool execution
|
||||
#
|
||||
# Modules:
|
||||
# connections — resolve gitea connection (connections.read)
|
||||
# lib — load gitea-client library
|
||||
# json — encode/decode (universal)
|
||||
# settings — user: default_owner, default_repo
|
||||
|
||||
gitea = lib.require("gitea-client")
|
||||
|
||||
|
||||
def _conn():
|
||||
"""Resolve the first available gitea connection."""
|
||||
return connections.get("gitea")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Surface API routes (/s/git-board/api/*)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
|
||||
conn = _conn()
|
||||
if conn == None:
|
||||
return _resp(400, {"error": "no gitea connection configured — add one in Settings > Connections"})
|
||||
|
||||
if method == "GET" and path == "/repos":
|
||||
return _handle_repos(conn)
|
||||
elif method == "GET" and path == "/board":
|
||||
return _handle_board(conn, req)
|
||||
elif method == "GET" and path.startswith("/issue/"):
|
||||
return _handle_get_issue(conn, path[len("/issue/"):])
|
||||
elif method == "POST" and path.startswith("/issue/"):
|
||||
return _handle_post_issue(conn, path[len("/issue/"):], req)
|
||||
elif method == "GET" and path.startswith("/ci/"):
|
||||
return _handle_ci(conn, path[len("/ci/"):])
|
||||
|
||||
return _resp(404, {"error": "not found"})
|
||||
|
||||
|
||||
def _handle_repos(conn):
|
||||
"""List repositories accessible via the connection."""
|
||||
repos = gitea.get_repos(conn)
|
||||
if repos == None:
|
||||
return _resp(502, {"error": "gitea request failed"})
|
||||
return _resp(200, {"data": repos})
|
||||
|
||||
|
||||
def _handle_board(conn, req):
|
||||
"""Combined issues + PRs for kanban board."""
|
||||
q = req.get("query", {})
|
||||
owner = _str(q.get("owner", "")) or settings.get("default_owner") or ""
|
||||
repo = _str(q.get("repo", "")) or settings.get("default_repo") or ""
|
||||
if not owner or not repo:
|
||||
return _resp(400, {"error": "owner and repo required (query params or user settings)"})
|
||||
|
||||
issues = gitea.get_issues(conn, owner, repo, "open") or []
|
||||
prs = gitea.get_prs(conn, owner, repo, "open") or []
|
||||
|
||||
board = {"issues": issues, "pull_requests": prs}
|
||||
return _resp(200, board)
|
||||
|
||||
|
||||
def _handle_get_issue(conn, issue_path):
|
||||
"""Get issue detail: /issue/:owner/:repo/:number"""
|
||||
parts = issue_path.split("/", 2)
|
||||
if len(parts) < 3:
|
||||
return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
|
||||
owner, repo, num = parts[0], parts[1], int(parts[2])
|
||||
data = gitea.get_issue(conn, owner, repo, num)
|
||||
if data == None:
|
||||
return _resp(404, {"error": "issue not found"})
|
||||
return _resp(200, data)
|
||||
|
||||
|
||||
def _handle_post_issue(conn, issue_path, req):
|
||||
"""Update issue or add comment: /issue/:owner/:repo/:number[/comment]"""
|
||||
parts = issue_path.split("/", 3)
|
||||
if len(parts) < 3:
|
||||
return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
|
||||
owner, repo, num = parts[0], parts[1], int(parts[2])
|
||||
action = parts[3] if len(parts) > 3 else ""
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
|
||||
if action == "comment":
|
||||
text = body.get("body", "")
|
||||
if not text:
|
||||
return _resp(400, {"error": "comment body required"})
|
||||
result = gitea.add_comment(conn, owner, repo, num, text)
|
||||
if result == None:
|
||||
return _resp(502, {"error": "failed to add comment"})
|
||||
return _resp(201, result)
|
||||
|
||||
# Default: update issue fields (state, title, body, assignee)
|
||||
title = body.get("title", "")
|
||||
issue_body = body.get("body", "")
|
||||
state = body.get("state", "")
|
||||
result = gitea.update_issue(conn, owner, repo, num, title, issue_body, state)
|
||||
if result == None:
|
||||
return _resp(502, {"error": "failed to update issue"})
|
||||
return _resp(200, result)
|
||||
|
||||
|
||||
def _handle_ci(conn, ref_path):
|
||||
"""CI status for owner/repo/ref."""
|
||||
parts = ref_path.split("/", 2)
|
||||
if len(parts) < 3:
|
||||
return _resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
|
||||
owner, repo, ref = parts[0], parts[1], parts[2]
|
||||
result = gitea.get_ci_status(conn, owner, repo, ref)
|
||||
if result == None:
|
||||
return _resp(502, {"error": "CI status request failed"})
|
||||
return _resp(200, result)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# LLM Tools (called by AI during chat)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_tool_call(tool_name, params):
|
||||
conn = _conn()
|
||||
if conn == None:
|
||||
return {"error": "no gitea connection configured — add one in Settings > Connections"}
|
||||
|
||||
owner = params.get("owner", "")
|
||||
repo = params.get("repo", "")
|
||||
|
||||
if tool_name == "list_issues":
|
||||
state = params.get("state", "open")
|
||||
data = gitea.get_issues(conn, owner, repo, state)
|
||||
if data == None:
|
||||
return {"error": "failed to list issues"}
|
||||
return {"issues": data, "count": len(data)}
|
||||
|
||||
elif tool_name == "get_issue":
|
||||
num = int(params.get("number", 0))
|
||||
data = gitea.get_issue(conn, owner, repo, num)
|
||||
if data == None:
|
||||
return {"error": "issue not found"}
|
||||
return data
|
||||
|
||||
elif tool_name == "create_issue":
|
||||
data = gitea.create_issue(conn, owner, repo,
|
||||
params.get("title", ""),
|
||||
params.get("body", ""),
|
||||
params.get("labels", ""))
|
||||
if data == None:
|
||||
return {"error": "failed to create issue"}
|
||||
return data
|
||||
|
||||
elif tool_name == "update_issue":
|
||||
num = int(params.get("number", 0))
|
||||
data = gitea.update_issue(conn, owner, repo, num,
|
||||
params.get("title", ""),
|
||||
params.get("body", ""),
|
||||
params.get("state", ""))
|
||||
if data == None:
|
||||
return {"error": "failed to update issue"}
|
||||
return data
|
||||
|
||||
elif tool_name == "add_comment":
|
||||
num = int(params.get("number", 0))
|
||||
data = gitea.add_comment(conn, owner, repo, num, params.get("body", ""))
|
||||
if data == None:
|
||||
return {"error": "failed to add comment"}
|
||||
return data
|
||||
|
||||
elif tool_name == "list_pull_requests":
|
||||
state = params.get("state", "open")
|
||||
data = gitea.get_prs(conn, owner, repo, state)
|
||||
if data == None:
|
||||
return {"error": "failed to list PRs"}
|
||||
return {"pull_requests": data, "count": len(data)}
|
||||
|
||||
elif tool_name == "get_ci_status":
|
||||
ref = params.get("ref", "")
|
||||
data = gitea.get_ci_status(conn, owner, repo, ref)
|
||||
if data == None:
|
||||
return {"error": "failed to get CI status"}
|
||||
return data
|
||||
|
||||
return {"error": "unknown tool: " + tool_name}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Response helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _resp(status, data):
|
||||
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||
|
||||
def _str(v):
|
||||
"""Coerce Starlark query param to string."""
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"id": "gitea-client",
|
||||
"title": "Gitea API Client",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"version": "1.0.1",
|
||||
"description": "Shared Gitea client — connections, API helpers, optional data caching.",
|
||||
"author": "armature",
|
||||
|
||||
"permissions": ["api.http", "connections.read", "db.read", "db.write"],
|
||||
|
||||
"exports": [
|
||||
"get_repos", "get_issues", "get_issue", "create_issue",
|
||||
"update_issue", "add_comment", "get_prs", "get_ci_status"
|
||||
],
|
||||
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/repos"},
|
||||
{"method": "GET", "path": "/issues"},
|
||||
{"method": "GET", "path": "/issues/*"},
|
||||
{"method": "POST", "path": "/issues"},
|
||||
{"method": "GET", "path": "/prs"},
|
||||
{"method": "GET", "path": "/ci/*"},
|
||||
{"method": "POST", "path": "/sync"}
|
||||
],
|
||||
|
||||
"connections": [
|
||||
{
|
||||
"type": "gitea",
|
||||
"label": "Gitea Instance",
|
||||
"fields": {
|
||||
"base_url": {"type": "url", "required": "true", "label": "Server URL"},
|
||||
"api_token": {"type": "secret", "required": "true", "label": "API Token"},
|
||||
"org": {"type": "string", "required": "false", "label": "Default Org"}
|
||||
},
|
||||
"scopes": ["global", "team", "personal"]
|
||||
}
|
||||
],
|
||||
|
||||
"db_tables": {
|
||||
"repos": {
|
||||
"columns": {
|
||||
"connection_id": "text",
|
||||
"full_name": "text",
|
||||
"owner": "text",
|
||||
"name": "text",
|
||||
"description": "text",
|
||||
"html_url": "text",
|
||||
"open_issues": "integer",
|
||||
"synced_at": "timestamp"
|
||||
},
|
||||
"indexes": [["connection_id"]]
|
||||
},
|
||||
"issues": {
|
||||
"columns": {
|
||||
"connection_id": "text",
|
||||
"repo_full_name": "text",
|
||||
"number": "integer",
|
||||
"title": "text",
|
||||
"state": "text",
|
||||
"labels": "text",
|
||||
"assignee": "text",
|
||||
"body": "text",
|
||||
"created_at": "text",
|
||||
"synced_at": "timestamp"
|
||||
},
|
||||
"indexes": [["connection_id", "repo_full_name"]]
|
||||
}
|
||||
},
|
||||
|
||||
"schema_version": 1
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
# Gitea API Client — Library Package
|
||||
#
|
||||
# Shared Gitea client providing connection-backed API helpers.
|
||||
# Consumers call lib.require("gitea-client") and pass a conn dict
|
||||
# obtained from connections.get("gitea").
|
||||
#
|
||||
# Entry points:
|
||||
# on_request(req) — REST API routes for browser-side consumers
|
||||
# Exported functions — Starlark consumers via lib.require()
|
||||
#
|
||||
# Modules available (via library permissions):
|
||||
# http — outbound HTTP (api.http)
|
||||
# connections — credential resolution (connections.read)
|
||||
# db — ext_gitea_client_* tables (db.read, db.write)
|
||||
# json — encode/decode (universal)
|
||||
|
||||
load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp")
|
||||
load("star/repos.star", _repos_get = "get_repos")
|
||||
load("star/issues.star", _issues_get = "get_issues", _issue_get = "get_issue", _issue_create = "create_issue", _issue_update = "update_issue", _comment_add = "add_comment")
|
||||
load("star/ci.star", _ci_get = "get_ci_status")
|
||||
|
||||
# Re-export loaded functions so lib.require() can find them in globals.
|
||||
# Starlark load() names are file-local; explicit assignment makes them global.
|
||||
get_repos = _repos_get
|
||||
get_issues = _issues_get
|
||||
get_issue = _issue_get
|
||||
create_issue = _issue_create
|
||||
update_issue = _issue_update
|
||||
add_comment = _comment_add
|
||||
get_ci_status = _ci_get
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# REST API routes (/s/gitea-client/api/*)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
|
||||
conn = _resolve_conn()
|
||||
if conn == None:
|
||||
return resp(400, {"error": "no gitea connection configured"})
|
||||
|
||||
if method == "GET" and path == "/repos":
|
||||
repos = get_repos(conn)
|
||||
if repos == None:
|
||||
return resp(502, {"error": "gitea request failed"})
|
||||
return resp(200, {"data": repos})
|
||||
|
||||
elif method == "GET" and path == "/issues":
|
||||
q = req.get("query", {})
|
||||
owner = _str(q.get("owner", ""))
|
||||
repo = _str(q.get("repo", ""))
|
||||
state = _str(q.get("state", "")) or "open"
|
||||
if not owner or not repo:
|
||||
return resp(400, {"error": "owner and repo query params required"})
|
||||
issues = get_issues(conn, owner, repo, state)
|
||||
if issues == None:
|
||||
return resp(502, {"error": "gitea request failed"})
|
||||
return resp(200, {"data": issues, "count": len(issues)})
|
||||
|
||||
elif method == "GET" and path.startswith("/issues/"):
|
||||
parts = path[len("/issues/"):].split("/", 2)
|
||||
if len(parts) < 3:
|
||||
return resp(400, {"error": "path must be /issues/:owner/:repo/:number"})
|
||||
owner, repo, num = parts[0], parts[1], int(parts[2])
|
||||
issue = get_issue(conn, owner, repo, num)
|
||||
if issue == None:
|
||||
return resp(404, {"error": "issue not found"})
|
||||
return resp(200, issue)
|
||||
|
||||
elif method == "POST" and path == "/issues":
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
result = create_issue(conn, body.get("owner", ""), body.get("repo", ""),
|
||||
body.get("title", ""), body.get("body", ""),
|
||||
body.get("labels", ""))
|
||||
if result == None:
|
||||
return resp(502, {"error": "failed to create issue"})
|
||||
return resp(201, result)
|
||||
|
||||
elif method == "GET" and path == "/prs":
|
||||
q = req.get("query", {})
|
||||
owner = _str(q.get("owner", ""))
|
||||
repo = _str(q.get("repo", ""))
|
||||
state = _str(q.get("state", "")) or "open"
|
||||
if not owner or not repo:
|
||||
return resp(400, {"error": "owner and repo query params required"})
|
||||
prs = get_prs(conn, owner, repo, state)
|
||||
if prs == None:
|
||||
return resp(502, {"error": "gitea request failed"})
|
||||
return resp(200, {"data": prs, "count": len(prs)})
|
||||
|
||||
elif method == "GET" and path.startswith("/ci/"):
|
||||
parts = path[len("/ci/"):].split("/", 2)
|
||||
if len(parts) < 3:
|
||||
return resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
|
||||
owner, repo, ref = parts[0], parts[1], parts[2]
|
||||
result = get_ci_status(conn, owner, repo, ref)
|
||||
if result == None:
|
||||
return resp(502, {"error": "CI status request failed"})
|
||||
return resp(200, result)
|
||||
|
||||
elif method == "POST" and path == "/sync":
|
||||
return _handle_sync(conn, req)
|
||||
|
||||
return resp(404, {"error": "not found"})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# PR helper (not large enough for its own file)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def get_prs(conn, owner, repo, state):
|
||||
"""List pull requests for a repository."""
|
||||
state = state or "open"
|
||||
path = "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50"
|
||||
data = gitea_get(conn, path)
|
||||
if data == None:
|
||||
return None
|
||||
items = []
|
||||
for p in data:
|
||||
items.append({
|
||||
"number": p.get("number", 0),
|
||||
"title": p.get("title", ""),
|
||||
"state": p.get("state", ""),
|
||||
"head": p.get("head", {}).get("ref", ""),
|
||||
"base": p.get("base", {}).get("ref", ""),
|
||||
"user": (p.get("user") or {}).get("login", ""),
|
||||
"created_at": p.get("created_at", ""),
|
||||
"html_url": p.get("html_url", ""),
|
||||
"mergeable": p.get("mergeable", None),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Sync handler — cache repos/issues to db tables
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_sync(conn, req):
|
||||
"""Sync repos and issues into local cache tables."""
|
||||
repos = get_repos(conn)
|
||||
if repos == None:
|
||||
return resp(502, {"error": "failed to fetch repos"})
|
||||
|
||||
conn_id = conn.get("id", "default")
|
||||
|
||||
# Clear and re-insert repos
|
||||
db.exec("DELETE FROM repos WHERE connection_id = ?", [conn_id])
|
||||
for r in repos:
|
||||
db.exec(
|
||||
"INSERT INTO repos (connection_id, full_name, owner, name, description, html_url, open_issues, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))",
|
||||
[conn_id, r["full_name"], r["owner"], r["name"], r.get("description", ""), r.get("html_url", ""), r.get("open_issues", 0)]
|
||||
)
|
||||
|
||||
# Sync open issues for each repo
|
||||
issue_count = 0
|
||||
db.exec("DELETE FROM issues WHERE connection_id = ?", [conn_id])
|
||||
for r in repos:
|
||||
issues = get_issues(conn, r["owner"], r["name"], "open")
|
||||
if issues == None:
|
||||
continue
|
||||
for i in issues:
|
||||
labels_str = ",".join(i.get("labels", []))
|
||||
db.exec(
|
||||
"INSERT INTO issues (connection_id, repo_full_name, number, title, state, labels, assignee, body, created_at, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
|
||||
[conn_id, r["full_name"], i["number"], i["title"], i["state"], labels_str, i.get("assignee", ""), "", i.get("created_at", "")]
|
||||
)
|
||||
issue_count += 1
|
||||
|
||||
return resp(200, {"repos": len(repos), "issues": issue_count})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _resolve_conn():
|
||||
"""Resolve the first available gitea connection."""
|
||||
return connections.get("gitea")
|
||||
|
||||
def _str(v):
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
@@ -1,25 +0,0 @@
|
||||
# gitea-client — CI status operations
|
||||
|
||||
load("star/http_helpers.star", "gitea_get")
|
||||
|
||||
def get_ci_status(conn, owner, repo, ref):
|
||||
"""Get CI/CD job status for a commit or branch reference."""
|
||||
path = "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
|
||||
data = gitea_get(conn, path)
|
||||
if data == None:
|
||||
return None
|
||||
statuses = data.get("statuses", [])
|
||||
items = []
|
||||
for s in statuses:
|
||||
items.append({
|
||||
"context": s.get("context", s.get("name", "")),
|
||||
"state": s.get("state", s.get("status", "")),
|
||||
"description": s.get("description", ""),
|
||||
"target_url": s.get("target_url", ""),
|
||||
})
|
||||
return {
|
||||
"ref": ref,
|
||||
"state": data.get("state", ""),
|
||||
"statuses": items,
|
||||
"count": len(items),
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# gitea-client — HTTP helpers
|
||||
#
|
||||
# Shared HTTP utilities for Gitea API calls.
|
||||
# All functions take a `conn` dict from connections.get("gitea").
|
||||
|
||||
def auth_headers(conn):
|
||||
"""Build authorization headers from a connection dict."""
|
||||
token = conn.get("api_token", "")
|
||||
if not token:
|
||||
return {}
|
||||
return {"Authorization": "token " + token}
|
||||
|
||||
def _base(conn):
|
||||
"""Return base URL with trailing slash stripped."""
|
||||
url = conn.get("base_url", "")
|
||||
if url.endswith("/"):
|
||||
url = url[:-1]
|
||||
return url
|
||||
|
||||
def gitea_get(conn, path):
|
||||
"""GET request to Gitea API. Returns decoded body or None."""
|
||||
url = _base(conn) + "/api/v1" + path
|
||||
resp = http.get(url=url, headers=auth_headers(conn))
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
def gitea_post(conn, path, data):
|
||||
"""POST request to Gitea API. Returns decoded body or None."""
|
||||
url = _base(conn) + "/api/v1" + path
|
||||
hdrs = dict(auth_headers(conn), **{"Content-Type": "application/json"})
|
||||
resp = http.post(url=url, body=json.encode(data), headers=hdrs)
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
def gitea_patch(conn, path, data):
|
||||
"""PATCH request to Gitea API (via X-HTTP-Method-Override). Returns decoded body or None."""
|
||||
url = _base(conn) + "/api/v1" + path
|
||||
hdrs = dict(auth_headers(conn), **{
|
||||
"Content-Type": "application/json",
|
||||
"X-HTTP-Method-Override": "PATCH",
|
||||
})
|
||||
resp = http.post(url=url, body=json.encode(data), headers=hdrs)
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
def resp(status, data):
|
||||
"""Build a standard JSON response dict."""
|
||||
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||
@@ -1,94 +0,0 @@
|
||||
# gitea-client — Issue operations
|
||||
|
||||
load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch")
|
||||
|
||||
def get_issues(conn, owner, repo, state):
|
||||
"""List issues for a repository."""
|
||||
state = state or "open"
|
||||
path = "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues"
|
||||
data = gitea_get(conn, path)
|
||||
if data == None:
|
||||
return None
|
||||
items = []
|
||||
for i in data:
|
||||
labels = [l.get("name", "") for l in (i.get("labels") or [])]
|
||||
items.append({
|
||||
"number": i.get("number", 0),
|
||||
"title": i.get("title", ""),
|
||||
"state": i.get("state", ""),
|
||||
"labels": labels,
|
||||
"assignee": (i.get("assignee") or {}).get("login", ""),
|
||||
"created_at": i.get("created_at", ""),
|
||||
"updated_at": i.get("updated_at", ""),
|
||||
"html_url": i.get("html_url", ""),
|
||||
})
|
||||
return items
|
||||
|
||||
def get_issue(conn, owner, repo, number):
|
||||
"""Get full issue details including comments."""
|
||||
path = "/repos/" + owner + "/" + repo + "/issues/" + str(number)
|
||||
data = gitea_get(conn, path)
|
||||
if data == None:
|
||||
return None
|
||||
comments_data = gitea_get(conn, path + "/comments") or []
|
||||
comments = []
|
||||
for c in comments_data:
|
||||
comments.append({
|
||||
"user": (c.get("user") or {}).get("login", ""),
|
||||
"body": c.get("body", ""),
|
||||
"created_at": c.get("created_at", ""),
|
||||
})
|
||||
return {
|
||||
"number": data.get("number", 0),
|
||||
"title": data.get("title", ""),
|
||||
"body": data.get("body", ""),
|
||||
"state": data.get("state", ""),
|
||||
"labels": [l.get("name", "") for l in (data.get("labels") or [])],
|
||||
"assignee": (data.get("assignee") or {}).get("login", ""),
|
||||
"created_at": data.get("created_at", ""),
|
||||
"comments": comments,
|
||||
}
|
||||
|
||||
def create_issue(conn, owner, repo, title, body, labels):
|
||||
"""Create a new issue."""
|
||||
path = "/repos/" + owner + "/" + repo + "/issues"
|
||||
payload = {"title": title}
|
||||
if body:
|
||||
payload["body"] = body
|
||||
if labels:
|
||||
payload["labels"] = [l.strip() for l in labels.split(",") if l.strip()]
|
||||
data = gitea_post(conn, path, payload)
|
||||
if data == None:
|
||||
return None
|
||||
return {
|
||||
"number": data.get("number", 0),
|
||||
"title": data.get("title", ""),
|
||||
"html_url": data.get("html_url", ""),
|
||||
}
|
||||
|
||||
def update_issue(conn, owner, repo, number, title, body, state):
|
||||
"""Update an existing issue."""
|
||||
path = "/repos/" + owner + "/" + repo + "/issues/" + str(number)
|
||||
patch = {}
|
||||
if title:
|
||||
patch["title"] = title
|
||||
if body:
|
||||
patch["body"] = body
|
||||
if state:
|
||||
patch["state"] = state
|
||||
data = gitea_patch(conn, path, patch)
|
||||
if data == None:
|
||||
return None
|
||||
return {
|
||||
"number": data.get("number", 0),
|
||||
"state": data.get("state", ""),
|
||||
"title": data.get("title", ""),
|
||||
}
|
||||
|
||||
def add_comment(conn, owner, repo, number, body):
|
||||
"""Add a comment to an issue."""
|
||||
path = "/repos/" + owner + "/" + repo + "/issues/" + str(number) + "/comments"
|
||||
data = gitea_post(conn, path, {"body": body})
|
||||
if data == None:
|
||||
return None
|
||||
return {"id": data.get("id", 0), "body": data.get("body", "")}
|
||||
@@ -1,21 +0,0 @@
|
||||
# gitea-client — Repository operations
|
||||
|
||||
load("star/http_helpers.star", "gitea_get")
|
||||
|
||||
def get_repos(conn):
|
||||
"""List repositories accessible via the connection."""
|
||||
data = gitea_get(conn, "/repos/search?limit=50&sort=updated")
|
||||
if data == None:
|
||||
return None
|
||||
repos = data if type(data) == "list" else data.get("data", [])
|
||||
out = []
|
||||
for r in repos:
|
||||
out.append({
|
||||
"full_name": r.get("full_name", ""),
|
||||
"name": r.get("name", ""),
|
||||
"owner": r.get("owner", {}).get("login", ""),
|
||||
"description": r.get("description", ""),
|
||||
"html_url": r.get("html_url", ""),
|
||||
"open_issues": r.get("open_issues_count", 0),
|
||||
})
|
||||
return out
|
||||
@@ -1,22 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
**Status: Proof of Concept**
|
||||
|
||||
Task management surface rebuilt as a Starlark extension. Ships with core
|
||||
for SDK validation purposes, not as a permanent commitment.
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates all three trigger primitives (event bus, webhook, cron) and
|
||||
proves the full extension surface stack: Starlark API routes, ext_data
|
||||
tables, SDK UI components, and notifications.
|
||||
|
||||
## Graduation Criteria
|
||||
|
||||
To become a permanent core package, tasks must meet:
|
||||
|
||||
1. **Feature completeness** — assignees, comments, attachments, subtasks
|
||||
2. **Test coverage** — API contract tests in the ICD test runner
|
||||
3. **Performance** — tested at 1000+ tasks with acceptable load times
|
||||
4. **Design review** — UI/UX matches platform design language
|
||||
5. **No kernel special-casing** — runs purely through extension APIs
|
||||
@@ -1,280 +0,0 @@
|
||||
/* ── Tasks Surface Styles ────────────────── */
|
||||
|
||||
.ext-tasks-app {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-6) var(--sp-5);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────── */
|
||||
.ext-tasks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ext-tasks-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.ext-tasks-stats {
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ext-tasks-header__right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.ext-tasks-view-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius);
|
||||
padding: 2px;
|
||||
}
|
||||
.ext-tasks-view-tab {
|
||||
padding: 5px var(--sp-4);
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.ext-tasks-view-tab:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.ext-tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
|
||||
|
||||
/* ── List View ───────────────────────────── */
|
||||
.ext-tasks-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.ext-tasks-list__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ext-tasks-filter {
|
||||
padding: 5px var(--sp-3);
|
||||
font-size: 13px;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.ext-tasks-list__count {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ext-tasks-list__loading, .ext-tasks-list__empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-10) 0;
|
||||
color: var(--text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Task Card ───────────────────────────── */
|
||||
.ext-tasks-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.ext-tasks-card:hover {
|
||||
border-color: var(--border-light);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.ext-tasks-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.ext-tasks-card__priority { font-size: 10px; }
|
||||
.ext-tasks-card__title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ext-tasks-card__title:hover { color: var(--accent); }
|
||||
.ext-tasks-card__status {
|
||||
font-size: 11px;
|
||||
padding: 2px var(--sp-2);
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ext-tasks-card__status--todo { background: var(--accent-dim); color: var(--accent); }
|
||||
.ext-tasks-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
|
||||
.ext-tasks-card__status--done { background: var(--success-dim); color: var(--success); }
|
||||
.ext-tasks-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
|
||||
|
||||
.ext-tasks-card__desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
margin-top: var(--sp-2);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ext-tasks-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ext-tasks-card__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
opacity: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.ext-tasks-card:hover .ext-tasks-card__actions { opacity: 1; }
|
||||
|
||||
/* ── Inline buttons ──────────────────────── */
|
||||
.ext-tasks-btn {
|
||||
border: none;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.ext-tasks-btn--sm { padding: 2px var(--sp-2); font-size: 13px; }
|
||||
.ext-tasks-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.ext-tasks-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
||||
|
||||
/* ── Board View ──────────────────────────── */
|
||||
.ext-tasks-board {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--sp-3);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.ext-tasks-board__col {
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.ext-tasks-board__col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ext-tasks-board__col-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
background: var(--bg-hover);
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.ext-tasks-board__col-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.ext-tasks-board__card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.ext-tasks-board__card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ext-tasks-board__card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.ext-tasks-board__card-due {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
|
||||
/* ── Form ────────────────────────────────── */
|
||||
.ext-tasks-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.ext-tasks-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
|
||||
.ext-tasks-form__actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-1); }
|
||||
.ext-tasks-form textarea,
|
||||
.ext-tasks-form input,
|
||||
.ext-tasks-form select {
|
||||
width: 100%;
|
||||
padding: 7px var(--sp-3);
|
||||
font-size: 13px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font);
|
||||
}
|
||||
.ext-tasks-form textarea:focus,
|
||||
.ext-tasks-form input:focus,
|
||||
.ext-tasks-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Statusbar widget ────────────────────── */
|
||||
.ext-tasks-status-widget {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
}
|
||||
|
||||
/* ── Slot containers ─────────────────────── */
|
||||
.sw-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.sw-slot--statusbar {
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sw-slot--toolbar {
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
/**
|
||||
* Tasks — Surface Entry Point (v0.1.0)
|
||||
*
|
||||
* Task management surface using the v0.2.3 SDK:
|
||||
* sw.api.ext('tasks') — scoped API client
|
||||
* sw.ui.* — primitive components
|
||||
* sw.slots — statusbar widget
|
||||
* sw.actions — create-task action
|
||||
*/
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
var mount = document.getElementById('extension-mount');
|
||||
if (!mount) return;
|
||||
|
||||
var base = window.__BASE__ || '';
|
||||
var ver = window.__VERSION__ || '0';
|
||||
|
||||
// ── Boot SDK ───────────────────────────────
|
||||
try {
|
||||
if (!window.preact) {
|
||||
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
||||
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
||||
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
||||
window.preact = { h, render };
|
||||
window.hooks = hooksModule;
|
||||
window.html = htmModule.default.bind(h);
|
||||
}
|
||||
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
||||
await sdk.boot();
|
||||
} catch (e) {
|
||||
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var { html } = window;
|
||||
var { useState, useEffect, useCallback, useRef } = hooks;
|
||||
var { render } = preact;
|
||||
|
||||
// ── SDK modules ────────────────────────────
|
||||
var api = sw.api.ext('tasks');
|
||||
var { Button, FormField, Dialog, Spinner, Dropdown, Tabs, Banner } = sw.ui;
|
||||
|
||||
// ── Constants ──────────────────────────────
|
||||
var STATUSES = ['todo', 'in_progress', 'done', 'cancelled'];
|
||||
var PRIORITIES = ['low', 'medium', 'high', 'urgent'];
|
||||
|
||||
var STATUS_LABELS = {
|
||||
todo: 'To Do', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled'
|
||||
};
|
||||
var PRIORITY_COLORS = {
|
||||
low: 'var(--text-3)', medium: 'var(--accent)', high: 'var(--warning)', urgent: 'var(--danger)'
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// TaskForm — create / edit dialog
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function TaskForm({ task, onSave, onClose }) {
|
||||
var [title, setTitle] = useState(task ? task.title : '');
|
||||
var [desc, setDesc] = useState(task ? task.description : '');
|
||||
var [status, setStatus] = useState(task ? task.status : STATUSES[0]);
|
||||
var [priority, setPriority] = useState(task ? task.priority : 'medium');
|
||||
var [dueDate, setDueDate] = useState(task ? task.due_date : '');
|
||||
var [tags, setTags] = useState(task ? task.tags : '');
|
||||
var [saving, setSaving] = useState(false);
|
||||
var [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) { setError('Title is required'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
var data = { title: title.trim(), description: desc, status, priority, due_date: dueDate, tags };
|
||||
if (task) {
|
||||
await api.put('/items/' + task.id, data);
|
||||
} else {
|
||||
await api.post('/items', data);
|
||||
}
|
||||
onSave();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}>
|
||||
<form onSubmit=${handleSubmit} class="ext-tasks-form">
|
||||
${error && html`<${Banner} variant="danger" text=${error} />`}
|
||||
<${FormField} label="Title" required>
|
||||
<input type="text" value=${title} onInput=${e => setTitle(e.target.value)}
|
||||
placeholder="What needs to be done?" autofocus />
|
||||
<//>
|
||||
<${FormField} label="Description">
|
||||
<textarea rows="3" value=${desc} onInput=${e => setDesc(e.target.value)}
|
||||
placeholder="Details (optional)" />
|
||||
<//>
|
||||
<div class="ext-tasks-form__row">
|
||||
<${FormField} label="Status">
|
||||
<select value=${status} onChange=${e => setStatus(e.target.value)}>
|
||||
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
|
||||
</select>
|
||||
<//>
|
||||
<${FormField} label="Priority">
|
||||
<select value=${priority} onChange=${e => setPriority(e.target.value)}>
|
||||
${PRIORITIES.map(p => html`<option value=${p}>${p}</option>`)}
|
||||
</select>
|
||||
<//>
|
||||
</div>
|
||||
<div class="ext-tasks-form__row">
|
||||
<${FormField} label="Due Date">
|
||||
<input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} />
|
||||
<//>
|
||||
<${FormField} label="Tags">
|
||||
<input type="text" value=${tags} onInput=${e => setTags(e.target.value)}
|
||||
placeholder="comma-separated" />
|
||||
<//>
|
||||
</div>
|
||||
<div class="ext-tasks-form__actions">
|
||||
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
|
||||
<${Button} variant="primary" type="submit" disabled=${saving}>
|
||||
${saving ? 'Saving…' : (task ? 'Update' : 'Create')}
|
||||
<//>
|
||||
</div>
|
||||
</form>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// TaskList — filterable task table
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function TaskList({ items, loading, onEdit, onRefresh }) {
|
||||
var [filter, setFilter] = useState('all');
|
||||
|
||||
var filtered = items;
|
||||
if (filter !== 'all') {
|
||||
filtered = items.filter(function(t) { return t.status === filter; });
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!await sw.confirm('Delete this task?', { destructive: true })) return;
|
||||
await api.del('/items/' + id);
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
async function handleQuickStatus(id, newStatus) {
|
||||
await api.put('/items/' + id, { status: newStatus });
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="ext-tasks-list">
|
||||
<div class="ext-tasks-list__filters">
|
||||
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="ext-tasks-filter">
|
||||
<option value="all">All</option>
|
||||
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
|
||||
</select>
|
||||
<span class="ext-tasks-list__count">${filtered.length} task${filtered.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
|
||||
|
||||
${!loading && filtered.length === 0 && html`
|
||||
<div class="ext-tasks-list__empty">No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.</div>
|
||||
`}
|
||||
|
||||
${!loading && filtered.map(function(t) {
|
||||
return html`
|
||||
<div class="ext-tasks-card" key=${t.id}>
|
||||
<div class="ext-tasks-card__header">
|
||||
<span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
|
||||
●
|
||||
</span>
|
||||
<span class="ext-tasks-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
|
||||
<span class="ext-tasks-card__status ext-tasks-card__status--${t.status}">
|
||||
${STATUS_LABELS[t.status] || t.status}
|
||||
</span>
|
||||
</div>
|
||||
${t.description && html`
|
||||
<div class="ext-tasks-card__desc">${t.description}</div>
|
||||
`}
|
||||
<div class="ext-tasks-card__footer">
|
||||
${t.due_date && html`<span class="ext-tasks-card__due">Due: ${t.due_date}</span>`}
|
||||
${t.tags && html`<span class="ext-tasks-card__tags">${t.tags}</span>`}
|
||||
<div class="ext-tasks-card__actions">
|
||||
${t.status !== 'done' && html`
|
||||
<button class="ext-tasks-btn ext-tasks-btn--sm" onClick=${() => handleQuickStatus(t.id, 'done')}
|
||||
title="Mark done">✓</button>
|
||||
`}
|
||||
<button class="ext-tasks-btn ext-tasks-btn--sm ext-tasks-btn--danger" onClick=${() => handleDelete(t.id)}
|
||||
title="Delete">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// TaskBoard — kanban columns
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function TaskBoard({ items, loading, onEdit, onRefresh }) {
|
||||
async function handleQuickStatus(id, newStatus) {
|
||||
await api.put('/items/' + id, { status: newStatus });
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="ext-tasks-board">
|
||||
${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
|
||||
${!loading && STATUSES.map(function(status) {
|
||||
var col = items.filter(function(t) { return t.status === status; });
|
||||
return html`
|
||||
<div class="ext-tasks-board__col" key=${status}>
|
||||
<div class="ext-tasks-board__col-header">
|
||||
<span class="ext-tasks-board__col-title">${STATUS_LABELS[status] || status}</span>
|
||||
<span class="ext-tasks-board__col-count">${col.length}</span>
|
||||
</div>
|
||||
<div class="ext-tasks-board__col-body">
|
||||
${col.map(function(t) {
|
||||
return html`
|
||||
<div class="ext-tasks-board__card" key=${t.id} onClick=${() => onEdit(t)}>
|
||||
<div class="ext-tasks-board__card-title">
|
||||
<span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
|
||||
${t.title}
|
||||
</div>
|
||||
${t.due_date && html`<div class="ext-tasks-board__card-due">Due: ${t.due_date}</div>`}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// TaskApp — root component
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function TaskApp() {
|
||||
var [items, setItems] = useState([]);
|
||||
var [loading, setLoading] = useState(true);
|
||||
var [error, setError] = useState('');
|
||||
var [view, setView] = useState('list');
|
||||
var [editTask, setEditTask] = useState(null); // null = closed, {} = new, {id} = edit
|
||||
var [stats, setStats] = useState(null);
|
||||
|
||||
var loadItems = useCallback(async function () {
|
||||
setLoading(true);
|
||||
try {
|
||||
var res = await api.get('/items');
|
||||
setItems(res.data || res || []);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load tasks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
var loadStats = useCallback(async function () {
|
||||
try {
|
||||
var res = await api.get('/stats');
|
||||
setStats(res);
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
|
||||
useEffect(function () {
|
||||
loadItems();
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
// Live updates via event bus
|
||||
useEffect(function () {
|
||||
var off = sw.on('task.*', function () {
|
||||
loadItems();
|
||||
loadStats();
|
||||
});
|
||||
return off;
|
||||
}, []);
|
||||
|
||||
function handleSave() {
|
||||
setEditTask(null);
|
||||
loadItems();
|
||||
loadStats();
|
||||
}
|
||||
|
||||
var tabs = [
|
||||
{ id: 'list', label: 'List' },
|
||||
{ id: 'board', label: 'Board' },
|
||||
];
|
||||
|
||||
var Topbar = sw.shell?.Topbar;
|
||||
|
||||
return html`
|
||||
<div class="ext-tasks-app">
|
||||
${Topbar ? html`
|
||||
<${Topbar} title="Tasks">
|
||||
${stats && html`<span class="ext-tasks-stats">${stats.total || 0} total</span>`}
|
||||
<div class="ext-tasks-view-tabs">
|
||||
${tabs.map(function(tab) {
|
||||
return html`<button key=${tab.id}
|
||||
class="ext-tasks-view-tab ${view === tab.id ? 'ext-tasks-view-tab--active' : ''}"
|
||||
onClick=${() => setView(tab.id)}>${tab.label}</button>`;
|
||||
})}
|
||||
</div>
|
||||
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
|
||||
<//>
|
||||
` : html`
|
||||
<div class="ext-tasks-header">
|
||||
<h1 class="ext-tasks-title">Tasks</h1>
|
||||
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${error && html`<${Banner} variant="danger" text=${error} />`}
|
||||
|
||||
${view === 'list' && html`
|
||||
<${TaskList} items=${items} loading=${loading}
|
||||
onEdit=${setEditTask} onRefresh=${loadItems} />
|
||||
`}
|
||||
|
||||
${view === 'board' && html`
|
||||
<${TaskBoard} items=${items} loading=${loading}
|
||||
onEdit=${setEditTask} onRefresh=${loadItems} />
|
||||
`}
|
||||
|
||||
${editTask !== null && html`
|
||||
<${TaskForm} task=${editTask.id ? editTask : null}
|
||||
onSave=${handleSave}
|
||||
onClose=${() => setEditTask(null)} />
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Actions & Slots registration
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// Register create-task action
|
||||
sw.actions.register('tasks.create', {
|
||||
label: 'Create Task',
|
||||
handler: function () {
|
||||
// Emit event that the app listens to
|
||||
sw.emit('tasks.open-create', {}, { localOnly: true });
|
||||
},
|
||||
});
|
||||
|
||||
// Statusbar widget
|
||||
function TaskStatusWidget() {
|
||||
var [count, setCount] = useState(null);
|
||||
|
||||
useEffect(function () {
|
||||
async function load() {
|
||||
try {
|
||||
var res = await api.get('/stats');
|
||||
var open = (res.counts || {}).todo || 0;
|
||||
var inProg = (res.counts || {}).in_progress || 0;
|
||||
setCount(open + inProg);
|
||||
} catch (_) {}
|
||||
}
|
||||
load();
|
||||
var off = sw.on('task.*', load);
|
||||
return off;
|
||||
}, []);
|
||||
|
||||
if (count === null) return null;
|
||||
return html`<span class="ext-tasks-status-widget" title="Open tasks">${count} open task${count !== 1 ? 's' : ''}</span>`;
|
||||
}
|
||||
|
||||
sw.slots.register('statusbar', {
|
||||
id: 'tasks-open-count',
|
||||
component: TaskStatusWidget,
|
||||
priority: 50,
|
||||
});
|
||||
|
||||
|
||||
// ── Mount ──────────────────────────────────
|
||||
render(html`<${TaskApp} />`, mount);
|
||||
|
||||
})();
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"id": "tasks",
|
||||
"title": "Tasks",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"route": "/s/tasks",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.1.0",
|
||||
"icon": "✅",
|
||||
"description": "Task management surface with lifecycle events, webhook integration, and scheduled reminders.",
|
||||
"author": "armature",
|
||||
|
||||
"permissions": ["db.write", "notifications.send"],
|
||||
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/items"},
|
||||
{"method": "POST", "path": "/items"},
|
||||
{"method": "GET", "path": "/items/*"},
|
||||
{"method": "PUT", "path": "/items/*"},
|
||||
{"method": "DELETE", "path": "/items/*"},
|
||||
{"method": "GET", "path": "/stats"}
|
||||
],
|
||||
|
||||
"db_tables": {
|
||||
"items": {
|
||||
"columns": {
|
||||
"title": "text",
|
||||
"description": "text",
|
||||
"status": "text",
|
||||
"priority": "text",
|
||||
"assignee_id": "text",
|
||||
"creator_id": "text",
|
||||
"due_date": "text",
|
||||
"tags": "text",
|
||||
"updated_at": "text",
|
||||
"completed_at": "text"
|
||||
},
|
||||
"indexes": [
|
||||
["status"],
|
||||
["assignee_id"],
|
||||
["creator_id"]
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"triggers": [
|
||||
{
|
||||
"type": "event",
|
||||
"pattern": "task.*",
|
||||
"entry_point": "on_task_event"
|
||||
},
|
||||
{
|
||||
"type": "webhook",
|
||||
"slug": "create",
|
||||
"entry_point": "on_webhook"
|
||||
}
|
||||
],
|
||||
|
||||
"settings": {
|
||||
"statuses": {
|
||||
"type": "string",
|
||||
"label": "Statuses",
|
||||
"description": "Comma-separated task statuses",
|
||||
"default": "todo,in_progress,done,cancelled"
|
||||
},
|
||||
"priorities": {
|
||||
"type": "string",
|
||||
"label": "Priorities",
|
||||
"description": "Comma-separated task priorities",
|
||||
"default": "low,medium,high,urgent"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
# Tasks — Starlark Backend (v0.1.0)
|
||||
#
|
||||
# Task management extension using ext_data + notifications + triggers.
|
||||
#
|
||||
# Entry points:
|
||||
# on_request(req) → surface API routes
|
||||
# on_task_event(event) → event trigger (task.*)
|
||||
# on_webhook(req) → webhook trigger for external task creation
|
||||
#
|
||||
# Modules: db, json, notifications, settings
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _resp(status, data):
|
||||
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||
|
||||
def _str(v):
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
def _now():
|
||||
"""ISO-ish timestamp from Starlark (no time module — use db auto-created_at or pass from caller)."""
|
||||
return ""
|
||||
|
||||
def _statuses():
|
||||
raw = settings.get("statuses")
|
||||
if not raw:
|
||||
return ["todo", "in_progress", "done", "cancelled"]
|
||||
return [s.strip() for s in raw.split(",") if s.strip()]
|
||||
|
||||
def _priorities():
|
||||
raw = settings.get("priorities")
|
||||
if not raw:
|
||||
return ["low", "medium", "high", "urgent"]
|
||||
return [s.strip() for s in raw.split(",") if s.strip()]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Surface API routes (/s/tasks/api/*)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
|
||||
# GET /items — list tasks
|
||||
if method == "GET" and path == "/items":
|
||||
return _list_items(req)
|
||||
|
||||
# POST /items — create task
|
||||
if method == "POST" and path == "/items":
|
||||
return _create_item(req)
|
||||
|
||||
# GET /stats — aggregate counts
|
||||
if method == "GET" and path == "/stats":
|
||||
return _get_stats()
|
||||
|
||||
# GET /items/:id
|
||||
if method == "GET" and path.startswith("/items/"):
|
||||
return _get_item(path[len("/items/"):])
|
||||
|
||||
# PUT /items/:id
|
||||
if method == "PUT" and path.startswith("/items/"):
|
||||
return _update_item(path[len("/items/"):], req)
|
||||
|
||||
# DELETE /items/:id
|
||||
if method == "DELETE" and path.startswith("/items/"):
|
||||
return _delete_item(path[len("/items/"):])
|
||||
|
||||
return _resp(404, {"error": "not found"})
|
||||
|
||||
|
||||
def _list_items(req):
|
||||
q = req.get("query", {})
|
||||
filters = {}
|
||||
|
||||
status = _str(q.get("status", ""))
|
||||
if status:
|
||||
filters["status"] = status
|
||||
|
||||
assignee = _str(q.get("assignee_id", ""))
|
||||
if assignee:
|
||||
filters["assignee_id"] = assignee
|
||||
|
||||
priority = _str(q.get("priority", ""))
|
||||
if priority:
|
||||
filters["priority"] = priority
|
||||
|
||||
creator = _str(q.get("creator_id", ""))
|
||||
if creator:
|
||||
filters["creator_id"] = creator
|
||||
|
||||
order = _str(q.get("order", "")) or "created_at"
|
||||
limit_str = _str(q.get("limit", ""))
|
||||
limit = int(limit_str) if limit_str else 100
|
||||
|
||||
rows = db.query("items", filters=filters, order=order, limit=limit)
|
||||
return _resp(200, {"data": rows or []})
|
||||
|
||||
|
||||
def _create_item(req):
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
user_id = req.get("user_id", "")
|
||||
|
||||
title = _str(body.get("title", ""))
|
||||
if not title:
|
||||
return _resp(400, {"error": "title is required"})
|
||||
|
||||
statuses = _statuses()
|
||||
status = _str(body.get("status", ""))
|
||||
if not status:
|
||||
status = statuses[0] if statuses else "todo"
|
||||
|
||||
priorities = _priorities()
|
||||
priority = _str(body.get("priority", ""))
|
||||
if not priority:
|
||||
priority = priorities[0] if priorities else "medium"
|
||||
|
||||
row = db.insert("items", {
|
||||
"title": title,
|
||||
"description": _str(body.get("description", "")),
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"assignee_id": _str(body.get("assignee_id", "")) or user_id,
|
||||
"creator_id": user_id,
|
||||
"due_date": _str(body.get("due_date", "")),
|
||||
"tags": _str(body.get("tags", "")),
|
||||
"updated_at": "",
|
||||
"completed_at":"",
|
||||
})
|
||||
return _resp(201, row)
|
||||
|
||||
|
||||
def _get_item(item_id):
|
||||
rows = db.query("items", filters={"id": item_id}, limit=1)
|
||||
if not rows:
|
||||
return _resp(404, {"error": "task not found"})
|
||||
return _resp(200, rows[0])
|
||||
|
||||
|
||||
def _update_item(item_id, req):
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
user_id = req.get("user_id", "")
|
||||
|
||||
# Fetch existing to detect transitions
|
||||
existing = db.query("items", filters={"id": item_id}, limit=1)
|
||||
if not existing:
|
||||
return _resp(404, {"error": "task not found"})
|
||||
old = existing[0]
|
||||
|
||||
updates = {}
|
||||
for key in ["title", "description", "status", "priority", "assignee_id", "due_date", "tags"]:
|
||||
if key in body:
|
||||
updates[key] = _str(body[key])
|
||||
|
||||
# Detect completion transition
|
||||
new_status = updates.get("status", "")
|
||||
old_status = _str(old.get("status", ""))
|
||||
if new_status == "done" and old_status != "done":
|
||||
updates["completed_at"] = "now" # sentinel — db module handles timestamp
|
||||
|
||||
ok = db.update("items", item_id, updates)
|
||||
if not ok:
|
||||
return _resp(500, {"error": "update failed"})
|
||||
|
||||
# Notify creator on completion if assignee is different
|
||||
if new_status == "done" and old_status != "done":
|
||||
creator = _str(old.get("creator_id", ""))
|
||||
assignee = _str(old.get("assignee_id", ""))
|
||||
if creator and assignee and creator != assignee:
|
||||
notifications.send(
|
||||
creator,
|
||||
"Task completed: " + _str(old.get("title", "")),
|
||||
body=_str(old.get("title", "")) + " was marked done by assignee.",
|
||||
)
|
||||
|
||||
# Re-fetch updated row
|
||||
rows = db.query("items", filters={"id": item_id}, limit=1)
|
||||
return _resp(200, rows[0] if rows else {})
|
||||
|
||||
|
||||
def _delete_item(item_id):
|
||||
ok = db.delete("items", item_id)
|
||||
if not ok:
|
||||
return _resp(404, {"error": "task not found"})
|
||||
return _resp(200, {"deleted": True})
|
||||
|
||||
|
||||
def _get_stats():
|
||||
all_items = db.query("items", limit=10000)
|
||||
items = all_items or []
|
||||
counts = {}
|
||||
for s in _statuses():
|
||||
counts[s] = 0
|
||||
for item in items:
|
||||
st = _str(item.get("status", ""))
|
||||
if st in counts:
|
||||
counts[st] = counts[st] + 1
|
||||
else:
|
||||
counts[st] = 1
|
||||
return _resp(200, {"counts": counts, "total": len(items)})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Event trigger handler
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_task_event(event):
|
||||
"""Handle task lifecycle events emitted by the platform."""
|
||||
label = event.get("event_label", "")
|
||||
payload = event.get("event_payload", {})
|
||||
|
||||
if type(payload) == "string":
|
||||
payload = json.decode(payload) if payload else {}
|
||||
|
||||
# Could be used for audit logging, metrics, etc.
|
||||
# For now just a placeholder — actual notifications happen inline in _update_item.
|
||||
pass
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Webhook trigger handler
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_webhook(req):
|
||||
"""
|
||||
External task creation via webhook.
|
||||
POST /api/v1/hooks/tasks/create with JSON body:
|
||||
{ "title": "...", "description": "...", "priority": "...", "assignee_id": "..." }
|
||||
"""
|
||||
raw_body = req.get("body", "{}")
|
||||
body = json.decode(raw_body) if raw_body else {}
|
||||
|
||||
title = _str(body.get("title", ""))
|
||||
if not title:
|
||||
return {"status": 400, "body": json.encode({"error": "title is required"})}
|
||||
|
||||
priorities = _priorities()
|
||||
priority = _str(body.get("priority", ""))
|
||||
if not priority:
|
||||
priority = priorities[0] if priorities else "medium"
|
||||
|
||||
statuses = _statuses()
|
||||
status = statuses[0] if statuses else "todo"
|
||||
|
||||
row = db.insert("items", {
|
||||
"title": title,
|
||||
"description": _str(body.get("description", "")),
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"assignee_id": _str(body.get("assignee_id", "")),
|
||||
"creator_id": "",
|
||||
"due_date": _str(body.get("due_date", "")),
|
||||
"tags": _str(body.get("tags", "")),
|
||||
"updated_at": "",
|
||||
"completed_at":"",
|
||||
})
|
||||
|
||||
return {"status": 201, "body": json.encode(row), "headers": {"Content-Type": "application/json"}}
|
||||
Reference in New Issue
Block a user