Changeset 0.37.16 (#228)
This commit is contained in:
57
CHANGELOG.md
57
CHANGELOG.md
@@ -1,5 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
## [0.37.16.0] — 2026-03-24
|
||||
|
||||
### Summary
|
||||
|
||||
Projects Surface. Card-grid list + two-column detail view with inline ChatPane,
|
||||
context menu (color/archive/delete), sidebar sections (description, instructions,
|
||||
KBs, notes, files), star toggle, deep-linking, and search/sort. UserMenu in header
|
||||
on both views. Backend was already complete (16 protected + 2 admin endpoints,
|
||||
18 SDK methods); this changeset delivers the full Preact frontend.
|
||||
|
||||
### Added
|
||||
|
||||
- **Projects list view:** Card grid with search filter, activity/name sort,
|
||||
"+ New project" via `sw.prompt` dialog, UserMenu in header left-of-title.
|
||||
(`src/js/sw/surfaces/projects/list.js`, `src/css/sw-projects-surface.css`)
|
||||
- **Projects detail view:** Two-column layout — left panel for conversations
|
||||
(inline ChatPane, "+ New" channel creation, channel list with back nav),
|
||||
right sidebar for description/instructions textareas with save, collapsible
|
||||
Knowledge Bases/Notes/Files sections with "+" picker dropdowns.
|
||||
(`src/js/sw/surfaces/projects/detail.js`)
|
||||
- **Context menu:** "..." button opens dropdown with Rename, 8 color swatches,
|
||||
Archive/Unarchive toggle, Delete with confirm dialog. All actions persist
|
||||
via `PUT /api/v1/projects/:id`. (`detail.js`)
|
||||
- **Star toggle:** Optimistic ☆↔★ toggle, persists in `settings.starred`.
|
||||
(`detail.js`)
|
||||
- **Deep-linking:** `/projects/:id` loads detail view directly via
|
||||
`window.__PROJECT_ID__` template injection. Invalid IDs gracefully fall
|
||||
back to list view. (`src/js/sw/surfaces/projects/index.js`,
|
||||
`server/pages/loaders.go`, `server/pages/templates/surfaces/projects.html`)
|
||||
- **Surface registration:** `/projects` route with `:id` param, nginx proxy,
|
||||
docker-entrypoint FE route, conditional CSS/JS loading in `base.html`.
|
||||
(`server/pages/pages.go`, `nginx.conf`, `docker-entrypoint-fe.sh`,
|
||||
`server/pages/templates/base.html`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Picker section expand:** `openPicker()` now auto-expands the collapsed
|
||||
section when "+" is clicked, so the picker dropdown is visible without
|
||||
manually toggling the chevron first. (`detail.js`)
|
||||
|
||||
### Changed
|
||||
|
||||
- **UserMenu in header (list view):** Moved from bottom footer to header row,
|
||||
left of "Projects" title. Consistent with detail view placement. Removed
|
||||
`.sw-projects__list-footer` CSS class. (`list.js`, `sw-projects-surface.css`)
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **File upload:** UI is wired (dropzone + click handler) but backend returns
|
||||
"failed to save file metadata" — requires workspace storage layer. File
|
||||
upload (archive upload/download) is required for MVP; deferred to v0.37.17
|
||||
(Workspaces). Git integration is optional/post-MVP.
|
||||
- **Conversation names:** Channel list shows raw UUID instead of friendly name
|
||||
(channels created as type `direct` have no name field).
|
||||
|
||||
---
|
||||
|
||||
## [0.37.15.0] — 2026-03-23
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -76,6 +76,7 @@ $(page_proxy_block "= ${BASE_PATH}/login")
|
||||
$(page_proxy_block "${BASE_PATH}/admin")
|
||||
$(page_proxy_block "${BASE_PATH}/editor")
|
||||
$(page_proxy_block "${BASE_PATH}/notes")
|
||||
$(page_proxy_block "${BASE_PATH}/projects")
|
||||
$(page_proxy_block "${BASE_PATH}/settings")
|
||||
$(page_proxy_block "${BASE_PATH}/w")
|
||||
# v0.27.0: Extension surface page routes
|
||||
|
||||
456
docs/DESIGN-MULTI-FILE-STARLARK.md
Normal file
456
docs/DESIGN-MULTI-FILE-STARLARK.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# DESIGN — Multi-File Starlark Packages
|
||||
|
||||
Promotes Starlark scripts from a single inline blob to a proper
|
||||
file tree with `load()` support. Prerequisite for library packages.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
Three problems:
|
||||
|
||||
1. **Scripts live in the DB.** The installer injects `script.star`
|
||||
contents into `manifest["_starlark_script"]`. The runner reads
|
||||
the script from the manifest JSONB column at runtime. This means
|
||||
the entire script — no matter how large — is serialized into a
|
||||
JSON string inside a JSON object inside a database row.
|
||||
|
||||
2. **`load()` is disabled.** The sandbox callback hard-returns an
|
||||
error. There's no way to split code across files.
|
||||
|
||||
3. **Only static assets are extracted.** `extractableRelPath()`
|
||||
allows `js/`, `css/`, `assets/` — nothing else. Starlark files
|
||||
land in the DB, not on disk.
|
||||
|
||||
The hotfix in v0.37.14 (reading `script.star` from the archive and
|
||||
injecting it as `_starlark_script`) made single-file clean but
|
||||
didn't fix the underlying architecture.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Archive Structure
|
||||
|
||||
```
|
||||
gitea-client/
|
||||
├── manifest.json ← metadata only, no _starlark_script
|
||||
├── script.star ← entry point (required for starlark tier)
|
||||
├── star/ ← optional: additional modules
|
||||
│ ├── repos.star
|
||||
│ ├── issues.star
|
||||
│ ├── auth.star
|
||||
│ └── ci.star
|
||||
├── js/ ← optional: surface assets
|
||||
│ └── main.js
|
||||
└── css/
|
||||
└── main.css
|
||||
```
|
||||
|
||||
Convention: `script.star` is always the entry point. Submodules live
|
||||
in `star/`. This parallels `js/main.js` as the JS entry point with
|
||||
supporting files alongside it.
|
||||
|
||||
### Manifest Change
|
||||
|
||||
The manifest gains an optional `entry_point` field for packages that
|
||||
want a different entry script name. Default: `script.star`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gitea-client",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"entry_point": "script.star"
|
||||
}
|
||||
```
|
||||
|
||||
`_starlark_script` is no longer written to the manifest. The field
|
||||
is still read for backward compatibility (existing packages that have
|
||||
it in their DB row continue to work until reinstalled).
|
||||
|
||||
### Extraction
|
||||
|
||||
**`extractableRelPath()`** adds `star/` and bare `*.star` files:
|
||||
|
||||
```go
|
||||
var staticPrefixes = []string{"js/", "css/", "assets/", "star/"}
|
||||
|
||||
func extractableRelPath(name string) string {
|
||||
// Existing prefix matching for directories...
|
||||
|
||||
// Also extract bare .star files at archive root
|
||||
base := filepath.Base(name)
|
||||
if strings.HasSuffix(base, ".star") {
|
||||
// Strip leading directory (package-id/) if present
|
||||
if idx := strings.Index(name, "/"); idx >= 0 {
|
||||
rest := name[idx+1:]
|
||||
if rest == base || strings.HasPrefix(rest, "star/") {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
if name == base {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
After install, the packages directory looks like:
|
||||
|
||||
```
|
||||
/data/packages/gitea-client/
|
||||
├── script.star
|
||||
├── star/
|
||||
│ ├── repos.star
|
||||
│ ├── issues.star
|
||||
│ ├── auth.star
|
||||
│ └── ci.star
|
||||
├── js/
|
||||
│ └── main.js
|
||||
└── css/
|
||||
└── main.css
|
||||
```
|
||||
|
||||
### Installer Changes
|
||||
|
||||
**`packages.go` `InstallPackage()`:**
|
||||
|
||||
1. Stop injecting `_starlark_script` into the manifest. Remove the
|
||||
hotfix code that reads `script.star` from the archive and stuffs
|
||||
it into the manifest JSON.
|
||||
|
||||
2. Let `extractableRelPath()` handle `.star` files naturally — they
|
||||
get extracted to disk alongside `js/` and `css/`.
|
||||
|
||||
3. Validate: if `tier == "starlark"`, confirm the archive contains
|
||||
`script.star` (or the manifest's `entry_point` value). Return
|
||||
400 if missing.
|
||||
|
||||
### Runner Changes
|
||||
|
||||
**`runner.go` `ExecPackage()`:**
|
||||
|
||||
Replace manifest-based script loading with file-based:
|
||||
|
||||
```go
|
||||
func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration, rc *RunContext) (*Result, error) {
|
||||
if pkg.Status != models.PackageStatusActive {
|
||||
return nil, fmt.Errorf("package %q is %s, not active", pkg.ID, pkg.Status)
|
||||
}
|
||||
if pkg.Tier != models.ExtTierStarlark {
|
||||
return nil, fmt.Errorf("package %q is tier %s, not starlark", pkg.ID, pkg.Tier)
|
||||
}
|
||||
|
||||
// ── Load script from disk (primary) or manifest (legacy) ──
|
||||
script, err := r.loadScript(pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modules, err := r.buildModules(ctx, pkg.ID, pkg.Manifest, rc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
|
||||
}
|
||||
|
||||
// Build package-scoped load callback
|
||||
loader := r.packageLoader(pkg.ID, modules)
|
||||
|
||||
log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules))
|
||||
|
||||
return r.sandbox.ExecWithLoader(ctx, pkg.ID+"/script.star", script, modules, loader)
|
||||
}
|
||||
|
||||
func (r *Runner) loadScript(pkg *store.PackageRegistration) (string, error) {
|
||||
// Primary: read from disk
|
||||
if r.packagesDir != "" {
|
||||
entryPoint := "script.star"
|
||||
if ep, ok := pkg.Manifest["entry_point"].(string); ok && ep != "" {
|
||||
entryPoint = ep
|
||||
}
|
||||
path := filepath.Join(r.packagesDir, pkg.ID, entryPoint)
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
return string(data), nil
|
||||
}
|
||||
// Fall through to legacy
|
||||
}
|
||||
|
||||
// Legacy: inline in manifest
|
||||
script, ok := pkg.Manifest["_starlark_script"].(string)
|
||||
if ok && script != "" {
|
||||
return script, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("package %q: no script.star on disk and no _starlark_script in manifest", pkg.ID)
|
||||
}
|
||||
```
|
||||
|
||||
### Sandbox Changes
|
||||
|
||||
**`sandbox.go`** gains `ExecWithLoader()` — same as `Exec()` but
|
||||
accepts a `load` callback:
|
||||
|
||||
```go
|
||||
// LoadFunc resolves load("path") calls to Starlark source.
|
||||
// Returns the module's globals. The sandbox caches results per
|
||||
// thread (Starlark handles this via thread.Load dedup).
|
||||
type LoadFunc func(thread *starlark.Thread, module string) (starlark.StringDict, error)
|
||||
|
||||
func (s *Sandbox) ExecWithLoader(ctx context.Context, filename, source string,
|
||||
modules map[string]starlark.Value, loader LoadFunc) (*Result, error) {
|
||||
|
||||
// ... same as Exec() but thread.Load = loader instead of error ...
|
||||
}
|
||||
```
|
||||
|
||||
The existing `Exec()` becomes a thin wrapper that passes a
|
||||
nil/error loader for backward compatibility.
|
||||
|
||||
### Package-Scoped Loader
|
||||
|
||||
**`runner.go`** builds the load callback scoped to the package's
|
||||
directory:
|
||||
|
||||
```go
|
||||
func (r *Runner) packageLoader(pkgID string, modules map[string]starlark.Value) LoadFunc {
|
||||
if r.packagesDir == "" {
|
||||
return nil // no disk = no load support
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(r.packagesDir, pkgID)
|
||||
cache := make(map[string]*loadEntry) // dedup + cycle detection
|
||||
|
||||
return func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
|
||||
// ── Security: reject traversal ──
|
||||
if strings.Contains(module, "..") || filepath.IsAbs(module) {
|
||||
return nil, fmt.Errorf("load: path traversal not allowed: %q", module)
|
||||
}
|
||||
|
||||
// ── Resolve to package directory ──
|
||||
resolved := filepath.Join(pkgDir, module)
|
||||
if !strings.HasPrefix(filepath.Clean(resolved), filepath.Clean(pkgDir)) {
|
||||
return nil, fmt.Errorf("load: path escapes package directory: %q", module)
|
||||
}
|
||||
|
||||
// ── Must be a .star file ──
|
||||
if !strings.HasSuffix(resolved, ".star") {
|
||||
return nil, fmt.Errorf("load: only .star files can be loaded: %q", module)
|
||||
}
|
||||
|
||||
// ── Cache / cycle detection ──
|
||||
if entry, ok := cache[module]; ok {
|
||||
if entry.loading {
|
||||
return nil, fmt.Errorf("load: circular dependency: %q", module)
|
||||
}
|
||||
return entry.globals, nil
|
||||
}
|
||||
|
||||
entry := &loadEntry{loading: true}
|
||||
cache[module] = entry
|
||||
|
||||
// ── Read and execute ──
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load: %q not found in package %q", module, pkgID)
|
||||
}
|
||||
|
||||
globals, err := starlark.ExecFile(thread, module, string(data),
|
||||
r.predeclaredForLoad(modules))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load: error in %q: %w", module, err)
|
||||
}
|
||||
|
||||
entry.globals = globals
|
||||
entry.loading = false
|
||||
return globals, nil
|
||||
}
|
||||
}
|
||||
|
||||
type loadEntry struct {
|
||||
loading bool
|
||||
globals starlark.StringDict
|
||||
}
|
||||
```
|
||||
|
||||
Key constraints:
|
||||
|
||||
- **Package-scoped.** Can only load files from within the package's
|
||||
own directory. Path traversal (`..`) and absolute paths are rejected.
|
||||
One package cannot load another package's files.
|
||||
|
||||
- **`.star` files only.** Cannot load `.js`, `.json`, or anything
|
||||
else. This is a Starlark sandbox, not a file reader.
|
||||
|
||||
- **Circular dependency detection.** If A loads B loads A → error.
|
||||
|
||||
- **Cached per invocation.** If `script.star` and `repos.star` both
|
||||
load `auth.star`, it executes once. Starlark's thread-level load
|
||||
dedup handles this, plus our explicit cache.
|
||||
|
||||
- **Same predeclared modules.** Loaded files get the same `db`,
|
||||
`http`, `json`, `settings`, `connections` modules as the entry
|
||||
point. They run in the same permission context.
|
||||
|
||||
### Starlark Usage
|
||||
|
||||
```python
|
||||
# script.star (entry point)
|
||||
load("star/auth.star", "auth_headers", "auth_headers_json")
|
||||
load("star/repos.star", "get_repos", "sync_repos")
|
||||
load("star/issues.star", "get_issues", "create_issue", "cache_issue")
|
||||
load("star/ci.star", "get_ci_status")
|
||||
|
||||
def on_request(req):
|
||||
# ... dispatch to imported functions ...
|
||||
|
||||
def on_tool_call(tool_name, params):
|
||||
# ... dispatch to imported functions ...
|
||||
```
|
||||
|
||||
```python
|
||||
# star/auth.star
|
||||
def auth_headers(conn):
|
||||
return {"Authorization": "token " + conn.get("api_token", "")}
|
||||
|
||||
def auth_headers_json(conn):
|
||||
h = auth_headers(conn)
|
||||
h["Content-Type"] = "application/json"
|
||||
return h
|
||||
```
|
||||
|
||||
```python
|
||||
# star/repos.star
|
||||
load("star/auth.star", "auth_headers")
|
||||
|
||||
def get_repos(conn):
|
||||
url = conn["base_url"] + "/api/v1/repos/search?limit=50"
|
||||
resp = http.get(url=url, headers=auth_headers(conn))
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
return json.decode(resp["body"])
|
||||
|
||||
def sync_repos(conn):
|
||||
repos = get_repos(conn)
|
||||
# ... db.insert into private tables ...
|
||||
```
|
||||
|
||||
Submodules can load other submodules. The dependency graph is a DAG
|
||||
(cycles are rejected).
|
||||
|
||||
### Build Script
|
||||
|
||||
**`packages/build.sh`** already includes `script.star`. Add `star/`:
|
||||
|
||||
```bash
|
||||
local dirs=""
|
||||
[ -d "$dir/js" ] && dirs="$dirs js/"
|
||||
[ -d "$dir/css" ] && dirs="$dirs css/"
|
||||
[ -d "$dir/assets" ] && dirs="$dirs assets/"
|
||||
[ -f "$dir/script.star" ] && dirs="$dirs script.star"
|
||||
[ -d "$dir/star" ] && dirs="$dirs star/"
|
||||
[ -d "$dir/migrations" ] && dirs="$dirs migrations/"
|
||||
```
|
||||
|
||||
### Runner Constructor
|
||||
|
||||
**`runner.go`** gains `packagesDir`:
|
||||
|
||||
```go
|
||||
type Runner struct {
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
packagesDir string // NEW
|
||||
notifier NotificationSender
|
||||
resolver ProviderResolver
|
||||
db *sql.DB
|
||||
dbPostgres bool
|
||||
}
|
||||
|
||||
func NewRunner(sb *Sandbox, stores store.Stores) *Runner {
|
||||
return &Runner{sandbox: sb, stores: stores}
|
||||
}
|
||||
|
||||
func (r *Runner) SetPackagesDir(dir string) {
|
||||
r.packagesDir = dir
|
||||
}
|
||||
```
|
||||
|
||||
**`main.go`** wires it:
|
||||
|
||||
```go
|
||||
starlarkRunner := sandbox.NewRunner(starlarkSandbox, stores)
|
||||
starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Existing package with `_starlark_script` in manifest, no files on disk | Works. `loadScript()` falls through to legacy path. |
|
||||
| Package installed with v0.37.14 hotfix (`script.star` injected into manifest) | Works. Same legacy path. |
|
||||
| New package with `script.star` on disk, no `_starlark_script` in manifest | Works. Primary path. |
|
||||
| New package with `script.star` + `star/` submodules | Works. Loader resolves `load()` calls. |
|
||||
| Package with both `_starlark_script` AND `script.star` on disk | Disk wins. Manifest is legacy fallback only. |
|
||||
| Package that calls `load()` but has no files on disk | Error: "load() not available (no package directory)". |
|
||||
|
||||
No migration needed. Old packages work as-is. New packages benefit
|
||||
from the file-based path. Reinstalling an old package extracts its
|
||||
`script.star` to disk (via the updated `extractableRelPath`).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
| Threat | Mitigation |
|
||||
|--------|------------|
|
||||
| Path traversal (`load("../../etc/passwd")`) | `..` rejected. `filepath.Clean` + prefix check against package dir. |
|
||||
| Absolute paths (`load("/etc/shadow")`) | `filepath.IsAbs` check → rejected. |
|
||||
| Loading non-Starlark files (`load("js/main.js")`) | `.star` suffix required. |
|
||||
| Cross-package load (`load("../other-pkg/script.star")`) | `..` rejected. Resolution is always relative to the package's own directory. |
|
||||
| Circular loads (`A→B→A`) | `loadEntry.loading` flag → error on re-entry. |
|
||||
| Symlink escape | `filepath.Clean` resolves symlinks. Production: packages dir should be on a non-symlink-following mount. |
|
||||
| Infinite load depth | Starlark's thread step limit applies across all loaded files. A package that loads 100 files still runs under the same 1M step budget. |
|
||||
|
||||
---
|
||||
|
||||
## Changeset Plan
|
||||
|
||||
| CS | Scope | Files |
|
||||
|----|-------|-------|
|
||||
| CS1 | Extraction | `packages.go`: `extractableRelPath()` adds `star/`, `*.star`. Remove `_starlark_script` injection from installer. Add entry point validation. |
|
||||
| CS2 | Sandbox | `sandbox.go`: `ExecWithLoader()`. `LoadFunc` type. Existing `Exec()` wraps with nil loader. |
|
||||
| CS3 | Runner | `runner.go`: `packagesDir` field + setter. `loadScript()` disk-first. `packageLoader()` with security checks. `ExecPackage()` uses `ExecWithLoader()`. |
|
||||
| CS4 | Wiring | `main.go`: `SetPackagesDir()`. `build.sh`: add `star/` to archive. |
|
||||
| CS5 | Test | Integration test: install package with `script.star` + `star/` submodules, call `on_request`, verify `load()` resolved correctly. Install package with only `_starlark_script` in manifest, verify legacy path. |
|
||||
|
||||
CS1-CS4 can land in a single session. CS5 validates. All five are
|
||||
backend-only — no FE changes.
|
||||
|
||||
---
|
||||
|
||||
## What This Enables
|
||||
|
||||
- **Library packages** — a `gitea-client` with `script.star` as
|
||||
entry point, `star/repos.star`, `star/issues.star`, `star/ci.star`
|
||||
as submodules. Clean separation of concerns.
|
||||
|
||||
- **Large extensions** — a workflow automation package with 20+
|
||||
tool handlers doesn't have to be one 2000-line file.
|
||||
|
||||
- **Shared utilities** — within a single package, common helpers
|
||||
(auth, validation, formatting) live in their own file and are
|
||||
loaded by multiple entry points.
|
||||
|
||||
- **Testable modules** — each `.star` file can be loaded and
|
||||
tested independently in a dev sandbox.
|
||||
|
||||
This does NOT enable cross-package loading. Package A cannot load
|
||||
Package B's files. That's what `lib.load()` is for (see
|
||||
DESIGN-EXT-CONNECTIONS-LIBRARIES.md). File-level `load()` is
|
||||
intra-package. `lib.load()` is inter-package. Different mechanisms,
|
||||
different trust boundaries.
|
||||
@@ -183,10 +183,11 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
|
||||
| v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned |
|
||||
| v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (−2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact |
|
||||
| v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~−1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes |
|
||||
| v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) |
|
||||
| v0.37.16 | Projects surface | Project views, note/channel associations (design-first) |
|
||||
| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) |
|
||||
| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) |
|
||||
| v0.37.15 | Workflow surfaces ✅ | Workflow ownership/lifecycle, stage CRUD, team-admin tabs, assignments queue |
|
||||
| v0.37.16 | Projects surface ✅ | Card grid + detail, inline ChatPane, context menu, sidebar pickers, deep-link |
|
||||
| v0.37.17 | Workspaces | File storage layer, archive upload/download (zip/tar), project file integration |
|
||||
| v0.37.18 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) |
|
||||
| v0.37.19 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) |
|
||||
|
||||
**v0.37.14 — Scorched Earth IV + Pane Audit ✅** (10 sessions, v0.37.14.0–.22):
|
||||
|
||||
@@ -203,15 +204,32 @@ Team-admin workflow management as first-class path. See
|
||||
Instance cancel/unclaim/reassign, stage CRUD FE wiring, team-admin
|
||||
workflow queue + stage editor + instance monitor surfaces.
|
||||
|
||||
**v0.37.16 — Projects Surface:**
|
||||
**v0.37.16 — Projects Surface ✅:**
|
||||
|
||||
Project views, note/channel associations, project-scoped navigation.
|
||||
Card-grid list + two-column detail (inline ChatPane, context menu, sidebar
|
||||
pickers, star toggle, deep-linking). UserMenu in header. Backend was already
|
||||
complete (16+2 endpoints, 18 SDK methods). File upload UI wired but backend
|
||||
storage deferred to v0.37.17.
|
||||
|
||||
**v0.37.17 — Debug + Model Surface:**
|
||||
**v0.37.17 — Workspaces (File Storage):**
|
||||
|
||||
Workspace storage layer for project files. Archive (zip, tar, etc.)
|
||||
upload/download is **required for MVP** — projects need document attachment.
|
||||
Schema and model FK already exist (`workspaces` table, `Project.WorkspaceID`).
|
||||
Git integration (clone, pull, branch tracking) is optional/post-MVP.
|
||||
|
||||
- [ ] Workspace CRUD endpoints wiring (create-on-demand for projects)
|
||||
- [ ] File upload backend: disk storage with workspace root path
|
||||
- [ ] Archive upload: zip/tar extraction + individual file indexing
|
||||
- [ ] File download: single file + archive download (zip bundle)
|
||||
- [ ] File browser UI in project sidebar (list, preview, delete)
|
||||
- [ ] Storage quota enforcement (`max_bytes` on workspace)
|
||||
|
||||
**v0.37.18 — Debug + Model Surface:**
|
||||
|
||||
Debug modal Preact rebuild, model configuration UI, provider diagnostics.
|
||||
|
||||
**v0.37.18 — Tag ("UI Complete"):**
|
||||
**v0.37.19 — Tag ("UI Complete"):**
|
||||
|
||||
`sw.can()` RBAC gates, `__USER__`/`__PAGE_DATA__` removal, `_getScale` SDK,
|
||||
light mode CSS audit, dead code hunt. Every capability has a UI.
|
||||
@@ -258,7 +276,7 @@ reading Go source code. Team admins build workflows visually.
|
||||
package management, backup/restore
|
||||
- [ ] Mobile-responsive layouts (proper mobile navigation,
|
||||
touch-optimized chat, not just CSS responsive)
|
||||
- [ ] Project creation dialog (replace `prompt()` with modal)
|
||||
- [x] Project creation dialog (v0.37.16, sw.prompt-based)
|
||||
- [ ] Admin-level project management (cross-instance visibility)
|
||||
|
||||
---
|
||||
@@ -297,10 +315,10 @@ No ordering constraints between items.
|
||||
- Memory analytics dashboard
|
||||
|
||||
**Projects**
|
||||
- `/p/:id` shared project view
|
||||
- Project-specific file uploads (own endpoint, storage path)
|
||||
- `/p/:id` shared project view (public/team-scoped read-only link)
|
||||
- Project templates (predefined configurations)
|
||||
- Sub-projects / nested hierarchy
|
||||
- Git integration (clone, pull, branch tracking — optional workspace feature)
|
||||
|
||||
**UX**
|
||||
- "Group" scope badge on model selector / KB list
|
||||
|
||||
@@ -80,6 +80,7 @@ server {
|
||||
location /team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /notes { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /projects { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
|
||||
# v0.27.0: Extension surface page routes → backend (Go templates)
|
||||
|
||||
241
packages/git-board/css/main.css
Normal file
241
packages/git-board/css/main.css
Normal file
@@ -0,0 +1,241 @@
|
||||
/* Git Board — Surface Styles */
|
||||
|
||||
.gb-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────── */
|
||||
|
||||
.gb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gb-header__left,
|
||||
.gb-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gb-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gb-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 8px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
/* ── Token Setup ─────────────────────────── */
|
||||
|
||||
.gb-setup {
|
||||
max-width: 480px;
|
||||
margin: 60px auto;
|
||||
text-align: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.gb-setup h2 {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.gb-setup p {
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.gb-setup__form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gb-setup__input {
|
||||
flex: 1;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gb-setup__input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.gb-setup__hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Kanban Board ────────────────────────── */
|
||||
|
||||
.gb-board {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.gb-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;
|
||||
}
|
||||
|
||||
.gb-column__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.gb-column__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gb-column__count {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.gb-column__cards {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────── */
|
||||
|
||||
.gb-card {
|
||||
display: block;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gb-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.gb-card--pr {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.gb-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gb-card__number {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.gb-card__assignee {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gb-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;
|
||||
}
|
||||
|
||||
.gb-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gb-card__labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gb-card__labels .badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.gb-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────── */
|
||||
|
||||
.gb-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
287
packages/git-board/js/main.js
Normal file
287
packages/git-board/js/main.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Git Board — Surface Entry Point
|
||||
*
|
||||
* Kanban board for Gitea/GitHub issues and PRs.
|
||||
* Calls Starlark backend via /s/git-board/api/*.
|
||||
* Per-user API token via extension settings.
|
||||
*/
|
||||
(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 } = hooks;
|
||||
var { render } = preact;
|
||||
|
||||
var API = base + '/s/' + slug + '/api';
|
||||
|
||||
async function api(path) {
|
||||
var token = sw.auth._getToken();
|
||||
var resp = await fetch(API + path, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
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 [needsToken, setNeedsToken] = useState(false);
|
||||
var menuRef = useRef(null);
|
||||
|
||||
// Mount user menu
|
||||
useEffect(function () {
|
||||
if (menuRef.current && sw.userMenu) {
|
||||
sw.userMenu(menuRef.current, { placement: 'down-right' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load repos on mount
|
||||
useEffect(function () {
|
||||
api('/repos').then(function (d) {
|
||||
if (d.error && d.error.indexOf('failed') !== -1) {
|
||||
setNeedsToken(true);
|
||||
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('failed') !== -1) setNeedsToken(true);
|
||||
} else {
|
||||
setBoard(d);
|
||||
setNeedsToken(false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, [owner, repo]);
|
||||
|
||||
useEffect(function () { loadBoard(); }, [loadBoard]);
|
||||
|
||||
if (needsToken) return html`
|
||||
<${TokenSetup} menuRef=${menuRef} />
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="gb-shell">
|
||||
<header class="gb-header">
|
||||
<div class="gb-header__left">
|
||||
<h1 class="gb-title">Git Board</h1>
|
||||
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
||||
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
||||
</div>
|
||||
<div class="gb-header__right">
|
||||
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
|
||||
${loading ? '↻' : 'Refresh'}
|
||||
</button>
|
||||
<div ref=${menuRef}></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
${board ? html`<${Board} data=${board} />` : html`
|
||||
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Token Setup ────────────────────────────
|
||||
|
||||
function TokenSetup({ menuRef }) {
|
||||
var [token, setToken] = useState('');
|
||||
var [saving, setSaving] = useState(false);
|
||||
|
||||
async function save() {
|
||||
if (!token.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await sw.api.extensions.updateUserSettings('git-board', { api_token: token.trim() });
|
||||
sw.toast('Token saved — reloading', 'success');
|
||||
setTimeout(function () { location.reload(); }, 500);
|
||||
} catch (e) {
|
||||
sw.toast(e.message || 'Failed to save', 'error');
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="gb-shell">
|
||||
<header class="gb-header">
|
||||
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
|
||||
<div class="gb-header__right"><div ref=${menuRef}></div></div>
|
||||
</header>
|
||||
<div class="gb-setup">
|
||||
<h2>Connect Your Account</h2>
|
||||
<p>Enter your personal access token for Gitea or GitHub.<br/>
|
||||
The admin configures the platform URL. You provide your own token.</p>
|
||||
<div class="gb-setup__form">
|
||||
<input type="password" class="gb-setup__input"
|
||||
placeholder="ghp_... or gitea token"
|
||||
value=${token} onInput=${function (e) { setToken(e.target.value); }} />
|
||||
<button class="btn-small btn-primary" onClick=${save} disabled=${saving || !token.trim()}>
|
||||
${saving ? 'Saving…' : 'Save Token'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="gb-setup__hint">Token is stored in your personal extension settings — not shared with other users.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Repo Picker ────────────────────────────
|
||||
|
||||
function RepoPicker({ repos, owner, repo, onSelect }) {
|
||||
var current = owner + '/' + repo;
|
||||
return html`
|
||||
<select class="gb-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 ───────────────────────────
|
||||
|
||||
var COLUMNS = [
|
||||
{ key: 'open', title: 'Open', filter: function (i) { return i.state === 'open'; } },
|
||||
{ key: 'in_progress', title: 'In Progress', filter: function (i) { return i.assignee && i.state === 'open'; } },
|
||||
{ key: 'prs', title: 'Pull Requests', isPR: true },
|
||||
{ key: 'closed', title: 'Done', filter: function (i) { return i.state === 'closed'; } },
|
||||
];
|
||||
|
||||
function Board({ data }) {
|
||||
var issues = data.issues || [];
|
||||
var prs = data.pull_requests || [];
|
||||
|
||||
// Split issues: unassigned open vs assigned open
|
||||
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="gb-board">
|
||||
<${Column} title="Open" count=${openUnassigned.length}>
|
||||
${openUnassigned.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
|
||||
<//>
|
||||
<${Column} title="In Progress" count=${inProgress.length}>
|
||||
${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
|
||||
<//>
|
||||
<${Column} title="Pull Requests" count=${prs.length}>
|
||||
${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
|
||||
<//>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function Column({ title, count, children }) {
|
||||
return html`
|
||||
<div class="gb-column">
|
||||
<div class="gb-column__header">
|
||||
<span class="gb-column__title">${title}</span>
|
||||
<span class="gb-column__count">${count || 0}</span>
|
||||
</div>
|
||||
<div class="gb-column__cards">${children}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function IssueCard({ issue }) {
|
||||
return html`
|
||||
<a class="gb-card" href=${issue.html_url} target="_blank" rel="noopener">
|
||||
<div class="gb-card__header">
|
||||
<span class="gb-card__number">#${issue.number}</span>
|
||||
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||
</div>
|
||||
<div class="gb-card__title">${esc(issue.title)}</div>
|
||||
<div class="gb-card__labels">
|
||||
${(issue.labels || []).map(function (l) {
|
||||
return html`<span key=${l} class="badge">${esc(l)}</span>`;
|
||||
})}
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function PRCard({ pr }) {
|
||||
return html`
|
||||
<a class="gb-card gb-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
||||
<div class="gb-card__header">
|
||||
<span class="gb-card__number">#${pr.number}</span>
|
||||
<span class="gb-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
||||
</div>
|
||||
<div class="gb-card__title">${esc(pr.title)}</div>
|
||||
<div class="gb-card__meta">
|
||||
<span>@${esc(pr.user)}</span>
|
||||
<span>${timeAgo(pr.created_at)}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
})();
|
||||
125
packages/git-board/manifest.json
Normal file
125
packages/git-board/manifest.json
Normal file
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"id": "git-board",
|
||||
"title": "Git Board",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"route": "/s/git-board",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.1.0",
|
||||
"description": "Gitea/GitHub issue and PR board with LLM tools for reading, editing, and commenting. Per-user API key configuration.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["api.http"],
|
||||
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/repos"},
|
||||
{"method": "GET", "path": "/board"},
|
||||
{"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": {
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"label": "Platform",
|
||||
"description": "Git platform type: gitea or github",
|
||||
"default": "gitea"
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"label": "Base URL",
|
||||
"description": "Git platform API base URL (e.g. https://git.gobha.me)",
|
||||
"required": true
|
||||
},
|
||||
"api_token": {
|
||||
"type": "string",
|
||||
"label": "API Token (personal)",
|
||||
"description": "Your personal access token. Set per-user in Settings > Extensions.",
|
||||
"default": ""
|
||||
},
|
||||
"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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
328
packages/git-board/script.star
Normal file
328
packages/git-board/script.star
Normal file
@@ -0,0 +1,328 @@
|
||||
# Git Board — Starlark Backend
|
||||
#
|
||||
# Entry points:
|
||||
# on_request(req) → surface API routes
|
||||
# on_tool_call(tool_name, params) → LLM tool execution
|
||||
#
|
||||
# Modules:
|
||||
# http — outbound HTTP to git platform (requires api.http permission)
|
||||
# settings — admin: platform, base_url / user: api_token, default_owner, default_repo
|
||||
# json — json.encode() / json.decode() (universal, no permission needed)
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Surface API routes (/s/git-board/api/*)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
|
||||
if method == "GET" and path == "/repos":
|
||||
return _handle_repos(req)
|
||||
elif method == "GET" and path == "/board":
|
||||
return _handle_board(req)
|
||||
elif method == "GET" and path.startswith("/ci/"):
|
||||
return _handle_ci(req, path[len("/ci/"):])
|
||||
|
||||
return _resp(404, {"error": "not found"})
|
||||
|
||||
|
||||
def _handle_repos(req):
|
||||
"""List repositories accessible to the user."""
|
||||
platform = _platform()
|
||||
data = _git_get(_repos_path(platform))
|
||||
if data == None:
|
||||
return _resp(502, {"error": "git platform request failed"})
|
||||
|
||||
# Normalize — Gitea returns list, GitHub returns {items: [...]}
|
||||
repos = data if type(data) == "list" else data.get("items", data.get("repositories", []))
|
||||
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 _resp(200, {"data": out})
|
||||
|
||||
|
||||
def _handle_board(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)"})
|
||||
|
||||
platform = _platform()
|
||||
|
||||
issues = _git_get(_issues_path(platform, owner, repo, "open")) or []
|
||||
prs = _git_get(_prs_path(platform, owner, repo, "open")) or []
|
||||
|
||||
board = {"issues": [], "pull_requests": []}
|
||||
for i in issues:
|
||||
labels = [l.get("name", "") for l in (i.get("labels") or [])]
|
||||
board["issues"].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", ""),
|
||||
})
|
||||
for p in prs:
|
||||
board["pull_requests"].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", ""),
|
||||
"mergeable": p.get("mergeable", None),
|
||||
"user": (p.get("user") or {}).get("login", ""),
|
||||
"created_at": p.get("created_at", ""),
|
||||
"html_url": p.get("html_url", ""),
|
||||
})
|
||||
|
||||
return _resp(200, board)
|
||||
|
||||
|
||||
def _handle_ci(req, 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]
|
||||
platform = _platform()
|
||||
data = _git_get(_ci_path(platform, owner, repo, ref))
|
||||
if data == None:
|
||||
return _resp(502, {"error": "CI status request failed"})
|
||||
return _resp(200, data)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# LLM Tools (called by AI during chat)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_tool_call(tool_name, params):
|
||||
platform = _platform()
|
||||
owner = params.get("owner", "")
|
||||
repo = params.get("repo", "")
|
||||
|
||||
if tool_name == "list_issues":
|
||||
state = params.get("state", "open")
|
||||
data = _git_get(_issues_path(platform, owner, repo, state))
|
||||
if data == None:
|
||||
return {"error": "failed to list issues"}
|
||||
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", ""),
|
||||
})
|
||||
return {"issues": items, "count": len(items)}
|
||||
|
||||
elif tool_name == "get_issue":
|
||||
num = int(params.get("number", 0))
|
||||
data = _git_get(_issue_path(platform, owner, repo, num))
|
||||
if data == None:
|
||||
return {"error": "issue not found"}
|
||||
# Also fetch comments
|
||||
comments_data = _git_get(_issue_path(platform, owner, repo, num) + "/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,
|
||||
}
|
||||
|
||||
elif tool_name == "create_issue":
|
||||
body = {"title": params.get("title", "")}
|
||||
if params.get("body", ""):
|
||||
body["body"] = params["body"]
|
||||
if params.get("labels", ""):
|
||||
body["labels"] = [l.strip() for l in params["labels"].split(",") if l.strip()]
|
||||
data = _git_post(_issues_path(platform, owner, repo, ""), body)
|
||||
if data == None:
|
||||
return {"error": "failed to create issue"}
|
||||
return {"number": data.get("number", 0), "title": data.get("title", ""), "html_url": data.get("html_url", "")}
|
||||
|
||||
elif tool_name == "update_issue":
|
||||
num = int(params.get("number", 0))
|
||||
patch = {}
|
||||
if params.get("title", ""):
|
||||
patch["title"] = params["title"]
|
||||
if params.get("body", ""):
|
||||
patch["body"] = params["body"]
|
||||
if params.get("state", ""):
|
||||
patch["state"] = params["state"]
|
||||
data = _git_patch(_issue_path(platform, owner, repo, num), patch)
|
||||
if data == None:
|
||||
return {"error": "failed to update issue"}
|
||||
return {"number": data.get("number", 0), "state": data.get("state", ""), "title": data.get("title", "")}
|
||||
|
||||
elif tool_name == "add_comment":
|
||||
num = int(params.get("number", 0))
|
||||
data = _git_post(_issue_path(platform, owner, repo, num) + "/comments", {"body": params.get("body", "")})
|
||||
if data == None:
|
||||
return {"error": "failed to add comment"}
|
||||
return {"id": data.get("id", 0), "body": data.get("body", "")}
|
||||
|
||||
elif tool_name == "list_pull_requests":
|
||||
state = params.get("state", "open")
|
||||
data = _git_get(_prs_path(platform, owner, repo, state))
|
||||
if data == None:
|
||||
return {"error": "failed to list PRs"}
|
||||
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", ""),
|
||||
})
|
||||
return {"pull_requests": items, "count": len(items)}
|
||||
|
||||
elif tool_name == "get_ci_status":
|
||||
ref = params.get("ref", "")
|
||||
data = _git_get(_ci_path(platform, owner, repo, ref))
|
||||
if data == None:
|
||||
return {"error": "failed to get CI status"}
|
||||
# Normalize: Gitea returns {statuses: [...]}, GitHub returns {state, statuses: [...]}
|
||||
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)}
|
||||
|
||||
return {"error": "unknown tool: " + tool_name}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Git platform abstraction
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _platform():
|
||||
return settings.get("platform") or "gitea"
|
||||
|
||||
def _base_url():
|
||||
url = settings.get("base_url") or ""
|
||||
if url.endswith("/"):
|
||||
url = url[:-1]
|
||||
return url
|
||||
|
||||
def _token():
|
||||
return settings.get("api_token") or ""
|
||||
|
||||
def _api_prefix(platform):
|
||||
# Gitea: /api/v1, GitHub: "" (api.github.com already has /repos etc)
|
||||
if platform == "github":
|
||||
return ""
|
||||
return "/api/v1"
|
||||
|
||||
def _repos_path(platform):
|
||||
if platform == "github":
|
||||
return "/user/repos?per_page=50&sort=updated"
|
||||
return "/repos/search?limit=50&sort=updated"
|
||||
|
||||
def _issues_path(platform, owner, repo, state):
|
||||
if platform == "github":
|
||||
return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&per_page=50"
|
||||
return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues"
|
||||
|
||||
def _issue_path(platform, owner, repo, number):
|
||||
return "/repos/" + owner + "/" + repo + "/issues/" + str(number)
|
||||
|
||||
def _prs_path(platform, owner, repo, state):
|
||||
if platform == "github":
|
||||
return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&per_page=50"
|
||||
return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50"
|
||||
|
||||
def _ci_path(platform, owner, repo, ref):
|
||||
if platform == "github":
|
||||
return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
|
||||
return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# HTTP helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _auth_headers():
|
||||
token = _token()
|
||||
if not token:
|
||||
return {}
|
||||
platform = _platform()
|
||||
if platform == "github":
|
||||
return {"Authorization": "Bearer " + token, "Accept": "application/vnd.github.v3+json"}
|
||||
return {"Authorization": "token " + token}
|
||||
|
||||
def _git_get(path):
|
||||
url = _base_url() + _api_prefix(_platform()) + path
|
||||
resp = http.get(url=url, headers=_auth_headers())
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
def _git_post(path, data):
|
||||
url = _base_url() + _api_prefix(_platform()) + path
|
||||
resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json"}))
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
def _git_patch(path, data):
|
||||
url = _base_url() + _api_prefix(_platform()) + path
|
||||
resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json", "X-HTTP-Method-Override": "PATCH"}))
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 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)
|
||||
@@ -124,12 +124,18 @@ type TeamAdminPageData struct {
|
||||
Section string `json:"section"`
|
||||
}
|
||||
|
||||
// ProjectsPageData is what the projects surface receives.
|
||||
type ProjectsPageData struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
}
|
||||
|
||||
func (e *Engine) registerLoaders() {
|
||||
e.RegisterLoader("admin", e.adminLoader)
|
||||
e.RegisterLoader("chat", e.chatLoader)
|
||||
e.RegisterLoader("notes", e.notesLoader)
|
||||
e.RegisterLoader("settings", e.settingsLoader)
|
||||
e.RegisterLoader("team-admin", e.teamAdminLoader)
|
||||
e.RegisterLoader("projects", e.projectsLoader)
|
||||
}
|
||||
|
||||
// ListDataProviders returns the keys of all registered data providers.
|
||||
@@ -431,3 +437,8 @@ func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (e *Engine) projectsLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
projectID := c.Param("id")
|
||||
return &ProjectsPageData{ProjectID: projectID}, nil
|
||||
}
|
||||
|
||||
@@ -237,6 +237,12 @@ func (e *Engine) registerCoreSurfaces() {
|
||||
DataRequires: []string{"team-admin"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "projects", Route: "/projects/:id", AltRoutes: []string{"/projects"},
|
||||
Title: "Projects", Template: "surface-projects", Auth: "authenticated",
|
||||
DataRequires: []string{"projects"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "workflow", Route: "/w/:id",
|
||||
Title: "Workflow", Template: "workflow", Auth: "session",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-shell.css?v={{.Version}}">
|
||||
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
|
||||
{{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}}
|
||||
{{if eq .Surface "projects"}}{{template "css-projects" .}}{{end}}
|
||||
{{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}}
|
||||
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
||||
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
|
||||
@@ -96,6 +97,7 @@
|
||||
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
|
||||
{{else if eq .Surface "notes"}}{{template "surface-notes" .}}
|
||||
{{else if eq .Surface "settings"}}{{template "surface-settings" .}}
|
||||
{{else if eq .Surface "projects"}}{{template "surface-projects" .}}
|
||||
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
|
||||
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
|
||||
{{end}}
|
||||
@@ -132,6 +134,7 @@
|
||||
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
||||
{{if eq .Surface "notes"}}{{template "scripts-notes" .}}{{end}}
|
||||
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
||||
{{if eq .Surface "projects"}}{{template "scripts-projects" .}}{{end}}
|
||||
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
|
||||
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
||||
|
||||
32
server/pages/templates/surfaces/projects.html
Normal file
32
server/pages/templates/surfaces/projects.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{/*
|
||||
Projects surface — v0.37.16: Card grid + detail with inline chat.
|
||||
*/}}
|
||||
|
||||
{{define "surface-projects"}}
|
||||
<div id="projects-mount" style="height:100%;overflow:hidden;"></div>
|
||||
{{end}}
|
||||
|
||||
{{define "css-projects"}}
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-projects-surface.css?v={{.Version}}">
|
||||
{{end}}
|
||||
|
||||
{{define "scripts-projects"}}
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
window.__PROJECT_ID__ = '{{.Data.ProjectID}}';
|
||||
</script>
|
||||
|
||||
<script type="module" nonce="{{.CSPNonce}}">
|
||||
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
|
||||
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
|
||||
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
|
||||
const html = htm.bind(h);
|
||||
window.preact = window.preact || { h, render };
|
||||
window.hooks = window.hooks || hooksModule;
|
||||
window.html = window.html || html;
|
||||
|
||||
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
|
||||
await boot();
|
||||
|
||||
await import('{{.BasePath}}/js/sw/surfaces/projects/index.js?v={{.Version}}');
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
starlarkjson "go.starlark.net/lib/json"
|
||||
)
|
||||
|
||||
// ─── Configuration ───────────────────────────
|
||||
@@ -93,10 +94,12 @@ func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map
|
||||
var output strings.Builder
|
||||
var outputMu sync.Mutex
|
||||
|
||||
predeclared := make(starlark.StringDict, len(modules))
|
||||
predeclared := make(starlark.StringDict, len(modules)+1)
|
||||
for k, v := range modules {
|
||||
predeclared[k] = v
|
||||
}
|
||||
// json.encode / json.decode — universal, no permissions required (pure computation)
|
||||
predeclared["json"] = starlarkjson.Module
|
||||
|
||||
thread := &starlark.Thread{
|
||||
Name: s.config.Name,
|
||||
|
||||
671
src/css/sw-projects-surface.css
Normal file
671
src/css/sw-projects-surface.css
Normal file
@@ -0,0 +1,671 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Projects Surface CSS
|
||||
==========================================
|
||||
v0.37.16: Card grid list + detail with inline chat.
|
||||
sw-projects__ prefixed, BEM convention.
|
||||
========================================== */
|
||||
|
||||
/* ── Root ────────────────────────────────── */
|
||||
|
||||
.sw-projects {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── List View ───────────────────────────── */
|
||||
|
||||
.sw-projects__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px 40px;
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sw-projects__list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sw-projects__list-header .sw-projects__new-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sw-projects__list-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e8ed);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sw-projects__new-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-raised, #222227);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__new-btn:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
border-color: var(--border-light, #3a3a42);
|
||||
}
|
||||
|
||||
.sw-projects__search {
|
||||
width: 100%;
|
||||
padding: 10px 14px 10px 36px;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-surface, #18181b);
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__search:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
.sw-projects__search-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-projects__search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sw-projects__sort {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
.sw-projects__sort select {
|
||||
background: var(--bg-raised, #222227);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 6px;
|
||||
color: var(--text-2, #9898a8);
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Card Grid ───────────────────────────── */
|
||||
|
||||
.sw-projects__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sw-projects__card {
|
||||
background: var(--bg-surface, #18181b);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.sw-projects__card:hover {
|
||||
border-color: var(--border-light, #3a3a42);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sw-projects__card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e8ed);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sw-projects__card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-2, #9898a8);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sw-projects__card-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sw-projects__card-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-raised, #222227);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
.sw-projects__empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* ── Detail View ─────────────────────────── */
|
||||
|
||||
.sw-projects__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-projects__detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.sw-projects__back {
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__back:hover {
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
.sw-projects__detail-name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e8ed);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sw-projects__detail-name-input {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e8ed);
|
||||
background: var(--bg-surface, #18181b);
|
||||
border: 1px solid var(--accent, #6c9fff);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-projects__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-projects__icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sw-projects__icon-btn:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
.sw-projects__icon-btn--active {
|
||||
color: var(--warning, #eab308);
|
||||
}
|
||||
|
||||
/* ── Detail Body (2-column) ──────────────── */
|
||||
|
||||
.sw-projects__detail-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-projects__conversations {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-projects__conv-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-projects__conv-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 20px;
|
||||
}
|
||||
|
||||
.sw-projects__conv-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-projects__conv-item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-projects__conv-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text, #e8e8ed);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-projects__conv-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sw-projects__conv-empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Chat Embed ──────────────────────────── */
|
||||
|
||||
.sw-projects__chat-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-projects__chat-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__chat-back:hover {
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
/* ── Right Sidebar ───────────────────────── */
|
||||
|
||||
.sw-projects__sidebar {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border, #2e2e35);
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface, #18181b);
|
||||
}
|
||||
|
||||
/* ── Collapsible Sections ────────────────── */
|
||||
|
||||
.sw-projects__section {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-projects__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sw-projects__section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2, #9898a8);
|
||||
}
|
||||
|
||||
.sw-projects__section-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sw-projects__section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sw-projects__section-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__section-add:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
.sw-projects__section-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__section-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sw-projects__section-body {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-projects__section-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-raised, #222227);
|
||||
color: var(--text, #e8e8ed);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sw-projects__section-textarea:focus {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
}
|
||||
|
||||
.sw-projects__section-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ── Section Items (KBs, Notes, Files) ──── */
|
||||
|
||||
.sw-projects__section-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e8e8ed);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-projects__section-item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-projects__section-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-projects__section-item-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-projects__section-item-remove {
|
||||
display: none;
|
||||
font-size: 11px;
|
||||
color: var(--danger, #ef4444);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__section-item:hover .sw-projects__section-item-remove {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sw-projects__section-item-remove:hover {
|
||||
background: var(--danger-dim, rgba(239, 68, 68, 0.2));
|
||||
}
|
||||
|
||||
/* ── File Drop Zone ──────────────────────── */
|
||||
|
||||
.sw-projects__dropzone {
|
||||
border: 2px dashed var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sw-projects__dropzone:hover {
|
||||
border-color: var(--border-light, #3a3a42);
|
||||
}
|
||||
|
||||
.sw-projects__dropzone--active {
|
||||
border-color: var(--accent, #6c9fff);
|
||||
background: var(--accent-dim, rgba(108, 159, 255, 0.12));
|
||||
color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
/* ── Picker Dropdown ─────────────────────── */
|
||||
|
||||
.sw-projects__picker {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 10;
|
||||
width: 280px;
|
||||
margin-top: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-raised, #222227);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-projects__picker-item {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e8e8ed);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.sw-projects__picker-item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-projects__picker-hint {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #6b6b7b);
|
||||
}
|
||||
|
||||
/* ── Context Menu (... menu) ─────────────── */
|
||||
|
||||
.sw-projects__ctx-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 20;
|
||||
min-width: 180px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-raised, #222227);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.sw-projects__ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e8e8ed);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sw-projects__ctx-item:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
.sw-projects__ctx-item--danger {
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
.sw-projects__ctx-divider {
|
||||
height: 1px;
|
||||
background: var(--border, #2e2e35);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Color Swatches (in menu) ────────────── */
|
||||
|
||||
.sw-projects__color-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.sw-projects__color-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.sw-projects__color-swatch--active {
|
||||
border-color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sw-projects__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sw-projects__list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sw-projects__detail-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sw-projects__sidebar {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border, #2e2e35);
|
||||
max-height: 40vh;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
if (!sw?.api?.surfaces?.list) return;
|
||||
sw.api.surfaces.list().then(data => {
|
||||
const all = data || [];
|
||||
const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'workflow', 'workflow-landing']);
|
||||
const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'projects', 'workflow', 'workflow-landing']);
|
||||
setExtSurfaces(all.filter(s => !CORE.has(s.id)));
|
||||
}).catch(() => {});
|
||||
}, [authenticated]);
|
||||
@@ -53,6 +53,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const surfaces = [
|
||||
{ key: 'chat', label: 'Chat', icon: '\ud83d\udcac' },
|
||||
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' },
|
||||
{ key: 'projects', label: 'Projects', icon: '\ud83d\udcc1' },
|
||||
];
|
||||
|
||||
// Add extension surfaces fetched from API
|
||||
|
||||
429
src/js/sw/surfaces/projects/detail.js
Normal file
429
src/js/sw/surfaces/projects/detail.js
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* ProjectDetail — two-column detail view with inline chat + metadata sidebar.
|
||||
*
|
||||
* Props:
|
||||
* project — project object
|
||||
* onBack — () => void (return to list)
|
||||
* onUpdate — (deleted?: boolean) => void
|
||||
*
|
||||
* Left panel toggles: conversation list ↔ embedded ChatPane.
|
||||
* Right sidebar: collapsible sections (Description, Instructions, KBs, Notes, Files).
|
||||
*/
|
||||
const html = window.html;
|
||||
const h = window.preact.h;
|
||||
const { useState, useEffect, useCallback, useRef } = window.hooks;
|
||||
|
||||
import { ChatPane } from '../../components/chat-pane/index.js';
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
const COLORS = ['#4f8cff','#34d399','#f59e0b','#ef4444','#a78bfa','#ec4899','#06b6d4','#8b5cf6'];
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
function relTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const DIVS = [[60,'second'],[60,'minute'],[24,'hour'],[30,'day'],[12,'month'],[Infinity,'year']];
|
||||
let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (val < 10) return 'just now';
|
||||
for (const [div, unit] of DIVS) {
|
||||
if (val < div) { const n = Math.floor(val); return `${n} ${unit}${n!==1?'s':''} ago`; }
|
||||
val /= div;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function ProjectDetail({ project, onBack, onUpdate }) {
|
||||
const BASE = window.__BASE__ || '';
|
||||
const chatRef = useRef(null);
|
||||
|
||||
// ── State ──
|
||||
const [activeChannelId, setActiveChannelId] = useState(null);
|
||||
const [assoc, setAssoc] = useState({ channels: [], kbs: [], notes: [], files: [] });
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState(project.name || '');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [sections, setSections] = useState({ desc: true, instructions: false, kbs: false, notes: false, files: false });
|
||||
const [desc, setDesc] = useState(project.description || '');
|
||||
const [instructions, setInstructions] = useState(project.settings?.instructions || '');
|
||||
const [descDirty, setDescDirty] = useState(false);
|
||||
const [instrDirty, setInstrDirty] = useState(false);
|
||||
const [starred, setStarred] = useState(!!project.settings?.starred);
|
||||
|
||||
// Picker state
|
||||
const [pickerOpen, setPickerOpen] = useState('');
|
||||
const [pickerItems, setPickerItems] = useState([]);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
|
||||
// Drag state for files
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
// ── Load associations ──
|
||||
const loadAssoc = useCallback(async () => {
|
||||
const id = project.id;
|
||||
const [ch, kb, nt, fi] = await Promise.all([
|
||||
sw.api.projects.channels(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.kbs(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.notes(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.files(id).then(r => r?.data || r?.files || r || []).catch(() => []),
|
||||
]);
|
||||
setAssoc({ channels: ch, kbs: kb, notes: nt, files: fi });
|
||||
}, [project.id]);
|
||||
|
||||
useEffect(() => { loadAssoc(); }, [loadAssoc]);
|
||||
|
||||
// ── Toggle section ──
|
||||
function toggleSection(key) {
|
||||
setSections(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
// ── Name editing ──
|
||||
async function saveName() {
|
||||
const name = nameVal.trim();
|
||||
if (!name || name === project.name) { setEditingName(false); return; }
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { name });
|
||||
sw.toast('Renamed', 'success');
|
||||
setEditingName(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Description / Instructions save ──
|
||||
async function saveDesc() {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { description: desc });
|
||||
sw.toast('Description saved', 'success');
|
||||
setDescDirty(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function saveInstructions() {
|
||||
try {
|
||||
const settings = { ...(project.settings || {}), instructions };
|
||||
await sw.api.projects.update(project.id, { settings });
|
||||
sw.toast('Instructions saved', 'success');
|
||||
setInstrDirty(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Star toggle ──
|
||||
async function toggleStar() {
|
||||
const next = !starred;
|
||||
setStarred(next);
|
||||
try {
|
||||
const settings = { ...(project.settings || {}), starred: next };
|
||||
await sw.api.projects.update(project.id, { settings });
|
||||
} catch (e) { sw.toast(e.message, 'error'); setStarred(!next); }
|
||||
}
|
||||
|
||||
// ── Context menu actions ──
|
||||
async function handleColor(c) {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { color: c });
|
||||
sw.toast('Color updated', 'success');
|
||||
setMenuOpen(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { is_archived: !project.is_archived });
|
||||
sw.toast(project.is_archived ? 'Unarchived' : 'Archived', 'success');
|
||||
setMenuOpen(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
setMenuOpen(false);
|
||||
if (!await sw.confirm('Delete this project? This cannot be undone.', true)) return;
|
||||
try {
|
||||
await sw.api.projects.del(project.id);
|
||||
sw.toast('Project deleted', 'success');
|
||||
onUpdate(true);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Picker helpers ──
|
||||
async function openPicker(type) {
|
||||
if (pickerOpen === type) { setPickerOpen(''); return; }
|
||||
setPickerOpen(type);
|
||||
setSections(prev => ({ ...prev, [type]: true }));
|
||||
setPickerLoading(true);
|
||||
setPickerItems([]);
|
||||
try {
|
||||
let items = [];
|
||||
if (type === 'kbs') {
|
||||
const r = await sw.api.knowledge.list();
|
||||
const all = r?.data || r || [];
|
||||
const attached = new Set(assoc.kbs.map(k => k.id || k.kb_id));
|
||||
items = all.filter(k => !attached.has(k.id));
|
||||
} else if (type === 'notes') {
|
||||
const r = await sw.api.notes.list();
|
||||
const all = r?.data || r || [];
|
||||
const attached = new Set(assoc.notes.map(n => n.id || n.note_id));
|
||||
items = all.filter(n => !attached.has(n.id));
|
||||
}
|
||||
setPickerItems(items);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
setPickerLoading(false);
|
||||
}
|
||||
|
||||
async function pickItem(type, item) {
|
||||
try {
|
||||
if (type === 'kbs') await sw.api.projects.addKb(project.id, item.id, false);
|
||||
else if (type === 'notes') await sw.api.projects.addNote(project.id, item.id);
|
||||
sw.toast('Added', 'success');
|
||||
setPickerOpen('');
|
||||
loadAssoc();
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Remove handlers ──
|
||||
async function removeKb(kbId) {
|
||||
try { await sw.api.projects.removeKb(project.id, kbId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
async function removeNote(noteId) {
|
||||
try { await sw.api.projects.removeNote(project.id, noteId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
async function removeChannel(chId) {
|
||||
try { await sw.api.projects.removeChannel(project.id, chId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── File upload ──
|
||||
async function uploadFiles(files) {
|
||||
for (const file of files) {
|
||||
try {
|
||||
await sw.api.projects.uploadFile(project.id, file);
|
||||
sw.toast(`Uploaded ${file.name}`, 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
loadAssoc();
|
||||
}
|
||||
|
||||
function handleUploadClick() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = () => { if (input.files.length) uploadFiles(input.files); };
|
||||
input.click();
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
// ── New conversation ──
|
||||
async function newConversation() {
|
||||
try {
|
||||
const ch = await sw.api.channels.create({ type: 'direct', title: 'New Chat' });
|
||||
const chId = ch?.id || ch?.data?.id;
|
||||
if (chId) {
|
||||
await sw.api.projects.addChannel(project.id, chId);
|
||||
await loadAssoc();
|
||||
setActiveChannelId(chId);
|
||||
}
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Render: Picker dropdown ──
|
||||
function renderPicker(type, emptyHint) {
|
||||
if (pickerOpen !== type) return null;
|
||||
return html`<div class="sw-projects__picker">
|
||||
${pickerLoading && html`<div class="sw-projects__picker-hint">Loading\u2026</div>`}
|
||||
${!pickerLoading && pickerItems.length === 0 && html`<div class="sw-projects__picker-hint">${emptyHint}</div>`}
|
||||
${!pickerLoading && pickerItems.map(item => html`
|
||||
<div key=${item.id} class="sw-projects__picker-item" onClick=${() => pickItem(type, item)}>
|
||||
${item.title || item.name || item.id}
|
||||
</div>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Collapsible section ──
|
||||
function renderSection(key, title, count, content, addAction) {
|
||||
const open = sections[key];
|
||||
return html`<div class="sw-projects__section">
|
||||
<div class="sw-projects__section-header" onClick=${e => { if (e.target.closest('.sw-projects__section-add')) return; toggleSection(key); }}>
|
||||
<div>
|
||||
<span class="sw-projects__section-title">${title}</span>
|
||||
${count != null && html`<span class="sw-projects__section-count">${count}</span>`}
|
||||
</div>
|
||||
<div class="sw-projects__section-actions">
|
||||
${addAction && html`<button class="sw-projects__section-add"
|
||||
onClick=${e => { e.stopPropagation(); addAction(); }}>+</button>`}
|
||||
<span class=${'sw-projects__section-chevron' + (open ? ' sw-projects__section-chevron--open' : '')}>\u25B6</span>
|
||||
</div>
|
||||
</div>
|
||||
${open && html`<div class="sw-projects__section-body">${content}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Left panel ──
|
||||
function renderLeftPanel() {
|
||||
if (activeChannelId) {
|
||||
return html`<div class="sw-projects__chat-wrap">
|
||||
<div class="sw-projects__chat-back" onClick=${() => setActiveChannelId(null)}>
|
||||
\u2190 Back to conversations
|
||||
</div>
|
||||
<${ChatPane}
|
||||
channelId=${activeChannelId}
|
||||
standalone=${false}
|
||||
handleRef=${chatRef}
|
||||
className="sw-projects__chat-pane" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<div class="sw-projects__conversations">
|
||||
<div class="sw-projects__conv-header">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-2);">Conversations</span>
|
||||
<button class="sw-projects__new-btn" style="font-size:12px;padding:5px 12px;" onClick=${newConversation}>+ New</button>
|
||||
</div>
|
||||
<div class="sw-projects__conv-list">
|
||||
${assoc.channels.length === 0 && html`
|
||||
<div class="sw-projects__conv-empty">
|
||||
No conversations yet.<br/>
|
||||
<button class="sw-projects__new-btn" style="margin-top:12px;font-size:12px;padding:5px 12px;" onClick=${newConversation}>Start a conversation</button>
|
||||
</div>
|
||||
`}
|
||||
${assoc.channels.map(ch => {
|
||||
const chId = ch.id || ch.channel_id;
|
||||
return html`<div key=${chId} class="sw-projects__conv-item" onClick=${() => setActiveChannelId(chId)}>
|
||||
<div class="sw-projects__conv-title">${ch.title || ch.name || chId}</div>
|
||||
<div class="sw-projects__conv-meta">Last message ${relTime(ch.updated_at)}</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Right sidebar ──
|
||||
function renderSidebar() {
|
||||
return html`<div class="sw-projects__sidebar">
|
||||
${renderSection('desc', 'Description', null, html`
|
||||
<textarea class="sw-projects__section-textarea" rows="3"
|
||||
value=${desc}
|
||||
onInput=${e => { setDesc(e.target.value); setDescDirty(true); }}
|
||||
placeholder="Add a project description\u2026" />
|
||||
${descDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveDesc}>Save</button>`}
|
||||
`)}
|
||||
|
||||
${renderSection('instructions', 'Instructions', null, html`
|
||||
<textarea class="sw-projects__section-textarea" rows="4"
|
||||
value=${instructions}
|
||||
onInput=${e => { setInstructions(e.target.value); setInstrDirty(true); }}
|
||||
placeholder="Add instructions to tailor responses\u2026" />
|
||||
${instrDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveInstructions}>Save</button>`}
|
||||
`)}
|
||||
|
||||
${renderSection('kbs', 'Knowledge Bases', assoc.kbs.length, html`
|
||||
${assoc.kbs.length === 0 && html`<div class="sw-projects__section-hint">No knowledge bases linked.</div>`}
|
||||
${assoc.kbs.map(kb => html`
|
||||
<div key=${kb.id || kb.kb_id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${kb.name || kb.kb_id}</span>
|
||||
<button class="sw-projects__section-item-remove" onClick=${() => removeKb(kb.id || kb.kb_id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
${renderPicker('kbs', 'No knowledge bases available.')}
|
||||
`, () => openPicker('kbs'))}
|
||||
|
||||
${renderSection('notes', 'Notes', assoc.notes.length, html`
|
||||
${assoc.notes.length === 0 && html`<div class="sw-projects__section-hint">No notes linked.</div>`}
|
||||
${assoc.notes.map(n => html`
|
||||
<div key=${n.id || n.note_id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${n.title || n.note_id}</span>
|
||||
<span class="sw-projects__section-item-meta">${fmtDate(n.created_at || n.added_at)}</span>
|
||||
<button class="sw-projects__section-item-remove" onClick=${() => removeNote(n.id || n.note_id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
${renderPicker('notes', 'No notes available.')}
|
||||
`, () => openPicker('notes'))}
|
||||
|
||||
${renderSection('files', 'Files', assoc.files.length, html`
|
||||
${assoc.files.map(f => html`
|
||||
<div key=${f.id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${f.filename || f.name}</span>
|
||||
<span class="sw-projects__section-item-meta">${formatBytes(f.size_bytes || f.size)}</span>
|
||||
</div>
|
||||
`)}
|
||||
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
|
||||
onClick=${handleUploadClick}
|
||||
onDragOver=${e => { e.preventDefault(); setDragActive(true); }}
|
||||
onDragLeave=${() => setDragActive(false)}
|
||||
onDrop=${handleDrop}>
|
||||
${dragActive ? 'Drop files here' : 'Add PDFs, documents, or other text to reference in this project.'}
|
||||
</div>
|
||||
`, handleUploadClick)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Main render ──
|
||||
return html`<div class="sw-projects__detail">
|
||||
<div class="sw-projects__detail-header">
|
||||
<${UserMenu} placement="down-right" />
|
||||
<a class="sw-projects__back" onClick=${onBack}>\u2190 All projects</a>
|
||||
${editingName
|
||||
? html`<input class="sw-projects__detail-name-input"
|
||||
value=${nameVal}
|
||||
onInput=${e => setNameVal(e.target.value)}
|
||||
onBlur=${saveName}
|
||||
onKeyDown=${e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
autoFocus />`
|
||||
: html`<h2 class="sw-projects__detail-name"
|
||||
onClick=${() => { setNameVal(project.name || ''); setEditingName(true); }}>
|
||||
${(project.icon ? project.icon + ' ' : '') + project.name}
|
||||
</h2>`
|
||||
}
|
||||
<div class="sw-projects__header-actions">
|
||||
<button class=${'sw-projects__icon-btn' + (starred ? ' sw-projects__icon-btn--active' : '')}
|
||||
onClick=${toggleStar} title="Star project">
|
||||
${starred ? '\u2605' : '\u2606'}
|
||||
</button>
|
||||
<div style="position:relative">
|
||||
<button class="sw-projects__icon-btn"
|
||||
onClick=${() => setMenuOpen(!menuOpen)} title="More options">\u22EF</button>
|
||||
${menuOpen && html`
|
||||
<div class="sw-projects__ctx-menu">
|
||||
<button class="sw-projects__ctx-item"
|
||||
onClick=${() => { setMenuOpen(false); setNameVal(project.name || ''); setEditingName(true); }}>Rename</button>
|
||||
<div class="sw-projects__color-row">
|
||||
${COLORS.map(c => html`
|
||||
<span key=${c}
|
||||
class=${'sw-projects__color-swatch' + (project.color === c ? ' sw-projects__color-swatch--active' : '')}
|
||||
style=${'background:' + c}
|
||||
onClick=${() => handleColor(c)} />
|
||||
`)}
|
||||
</div>
|
||||
<div class="sw-projects__ctx-divider" />
|
||||
<button class="sw-projects__ctx-item" onClick=${toggleArchive}>
|
||||
${project.is_archived ? 'Unarchive' : 'Archive'}</button>
|
||||
<button class="sw-projects__ctx-item sw-projects__ctx-item--danger"
|
||||
onClick=${deleteProject}>Delete</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sw-projects__detail-body">
|
||||
${renderLeftPanel()}
|
||||
${renderSidebar()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
93
src/js/sw/surfaces/projects/index.js
Normal file
93
src/js/sw/surfaces/projects/index.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* ProjectsSurface — root view-switching component.
|
||||
*
|
||||
* No selectedId → card grid list (ProjectsList)
|
||||
* With selectedId → detail view (ProjectDetail)
|
||||
*
|
||||
* Globals: __PROJECT_ID__ (deep-link), __BASE__ (base path)
|
||||
*/
|
||||
const html = window.html;
|
||||
const { useState, useEffect, useCallback } = window.hooks;
|
||||
const { render } = window.preact;
|
||||
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
import { ProjectsList } from './list.js';
|
||||
import { ProjectDetail } from './detail.js';
|
||||
|
||||
function ProjectsSurface() {
|
||||
const BASE = window.__BASE__ || '';
|
||||
const deepLinkId = window.__PROJECT_ID__ || '';
|
||||
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(deepLinkId || '');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.projects.list();
|
||||
const list = resp?.data || resp || [];
|
||||
setProjects(list);
|
||||
if (deepLinkId && !list.find(p => p.id === deepLinkId)) {
|
||||
setSelectedId('');
|
||||
}
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [deepLinkId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const selected = projects.find(p => p.id === selectedId) || null;
|
||||
|
||||
function selectProject(id) {
|
||||
setSelectedId(id);
|
||||
history.replaceState(null, '', BASE + '/projects/' + id);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setSelectedId('');
|
||||
history.replaceState(null, '', BASE + '/projects');
|
||||
}
|
||||
|
||||
async function createProject(name) {
|
||||
try {
|
||||
const proj = await sw.api.projects.create({ name });
|
||||
sw.toast('Project created', 'success');
|
||||
await load();
|
||||
if (proj?.id) selectProject(proj.id);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
const handleUpdate = useCallback((deleted) => {
|
||||
if (deleted) { setSelectedId(''); history.replaceState(null, '', BASE + '/projects'); }
|
||||
load();
|
||||
}, [load, BASE]);
|
||||
|
||||
return html`
|
||||
<div class="sw-projects">
|
||||
${selected
|
||||
? html`<${ProjectDetail}
|
||||
key=${selectedId}
|
||||
project=${selected}
|
||||
onBack=${goBack}
|
||||
onUpdate=${handleUpdate} />`
|
||||
: html`<${ProjectsList}
|
||||
projects=${projects}
|
||||
loading=${loading}
|
||||
onSelect=${selectProject}
|
||||
onCreate=${createProject} />`
|
||||
}
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Mount ────────────────────────────────
|
||||
const mount = document.getElementById('projects-mount');
|
||||
if (mount) {
|
||||
render(html`<${ProjectsSurface} />`, mount);
|
||||
console.log('[projects] Surface mounted');
|
||||
}
|
||||
|
||||
export { ProjectsSurface };
|
||||
124
src/js/sw/surfaces/projects/list.js
Normal file
124
src/js/sw/surfaces/projects/list.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* ProjectsList — card grid view for all projects.
|
||||
*
|
||||
* Props:
|
||||
* projects — array of project objects
|
||||
* loading — boolean
|
||||
* onSelect — (id: string) => void
|
||||
* onCreate — (name: string) => void
|
||||
*/
|
||||
const html = window.html;
|
||||
const { useState, useMemo } = window.hooks;
|
||||
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
// ── Relative time helper ────────────────────
|
||||
const DIVISIONS = [
|
||||
[60, 'second'], [60, 'minute'], [24, 'hour'],
|
||||
[30, 'day'], [12, 'month'], [Infinity, 'year'],
|
||||
];
|
||||
function relativeTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (val < 10) return 'just now';
|
||||
for (const [div, unit] of DIVISIONS) {
|
||||
if (val < div) {
|
||||
const n = Math.floor(val);
|
||||
return `Updated ${n} ${unit}${n !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
val /= div;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function ProjectsList({ projects, loading, onSelect, onCreate }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState('activity');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
let list = projects;
|
||||
if (q) {
|
||||
list = list.filter(p =>
|
||||
(p.name || '').toLowerCase().includes(q) ||
|
||||
(p.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (sort === 'activity') {
|
||||
list = [...list].sort((a, b) =>
|
||||
new Date(b.updated_at || 0) - new Date(a.updated_at || 0)
|
||||
);
|
||||
} else {
|
||||
list = [...list].sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || '')
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [projects, search, sort]);
|
||||
|
||||
async function handleCreate() {
|
||||
const name = await sw.prompt('Project name:');
|
||||
if (name && name.trim()) onCreate(name.trim());
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="sw-projects__list">
|
||||
<div class="sw-projects__empty">Loading projects\u2026</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sw-projects__list">
|
||||
<div class="sw-projects__list-header">
|
||||
<${UserMenu} placement="down-right" />
|
||||
<h1 class="sw-projects__list-title">Projects</h1>
|
||||
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ New project</button>
|
||||
</div>
|
||||
|
||||
<div class="sw-projects__search-wrap">
|
||||
<span class="sw-projects__search-icon">\u{1F50D}</span>
|
||||
<input class="sw-projects__search"
|
||||
type="text"
|
||||
placeholder="Search projects\u2026"
|
||||
value=${search}
|
||||
onInput=${e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div class="sw-projects__sort">
|
||||
<span>Sort by</span>
|
||||
<select value=${sort} onChange=${e => setSort(e.target.value)}>
|
||||
<option value="activity">Activity</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${filtered.length === 0 && !loading && html`
|
||||
<div class="sw-projects__empty">
|
||||
${search ? 'No projects match your search.' : 'No projects yet.'}
|
||||
${!search && html`<div style="margin-top:12px;">
|
||||
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ Create your first project</button>
|
||||
</div>`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="sw-projects__grid">
|
||||
${filtered.map(p => html`
|
||||
<div key=${p.id}
|
||||
class="sw-projects__card"
|
||||
style=${p.color ? `border-left: 3px solid ${p.color}` : ''}
|
||||
onClick=${() => onSelect(p.id)}>
|
||||
<div class="sw-projects__card-name">
|
||||
${p.icon ? html`<span>${p.icon}</span>` : ''}
|
||||
<span>${p.name}</span>
|
||||
${p.is_archived && html`<span class="sw-projects__card-badge">archived</span>`}
|
||||
</div>
|
||||
${p.description && html`
|
||||
<div class="sw-projects__card-desc">${p.description}</div>
|
||||
`}
|
||||
<div class="sw-projects__card-meta">${relativeTime(p.updated_at)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user