No sessions to show
+Enable projects from the menu (⋯ → Manage Projects) to see their sessions.
+diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 0158fcc..0edaa0b 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -51,6 +51,7 @@ pi-web/ │ │ ├── new_session.go # New-session creation logic │ │ ├── git.go # /api/git/info, /api/git/rename-branch handlers │ │ ├── scratchpad.go # Per-project scratchpad get/save (SQLite) +│ │ ├── projects.go # Project visibility prefs: list/toggle/register + index filtering (SQLite) │ │ ├── sound.go # /api/sounds + /sounds/ asset serving │ │ ├── push.go # PushManager: VAPID, subscribe/unsubscribe, NotifyDone │ │ ├── update.go # /api/version, check-update, update, restart handlers @@ -116,9 +117,11 @@ and `RunRestart`. When `Updater` is nil the version/update routes are not regist when `RunInstall`/`RunRestart` are nil the corresponding endpoints respond `503`. On `New`, the server opens (and migrates) a SQLite database at -`~/.pi/agent/pi-web.sqlite` — currently a single `scratchpads` table keyed by -project path. A `PushManager` (when configured) persists web-push subscriptions and -VAPID keys under the agent dir. +`~/.pi/agent/pi-web.sqlite` — a `scratchpads` table keyed by project path, a +`project_prefs` table recording which projects are enabled, and an `app_settings` +key/value table holding the project-filter master switch (default off). See +`projects.go`. A `PushManager` (when configured) persists web-push subscriptions +and VAPID keys under the agent dir. ### `sessions.Session` @@ -208,6 +211,7 @@ type piRPCWorker struct { | `/api/git/info` | GET | `handleGitInfo` | Branch / dirty / PR-URL info for a project | | `/api/git/rename-branch` | POST | `handleGitRenameBranch` | Rename the current git branch | | `/api/scratchpad` | GET/POST | `handleGetScratchpad` / `handleSaveScratchpad` | Per-project scratchpad (SQLite) | +| `/api/projects` | GET/POST | `handleApiProjects` / `handleUpdateProject` | List projects + filter state; enable/disable/register/remove, bulk enable-all/disable-all, enable-filter/disable-filter (SQLite) | | `/api/sounds` | GET | `handleApiSounds` | List available notification sounds | | `/sounds/` | GET | `handleSounds` | Serve a sound asset (no auth) | | `/custom-themes.css` | GET | `handleCustomThemes` | User custom theme CSS | diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index b57d86e..dc04048 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -16,7 +16,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod | Live Updates | Server-Sent Events (SSE) | | Chat RPC | JSONL over stdin/stdout via `pi --mode rpc` | | Session Storage | JSONL files on disk; pi-web creates new session files and appends `session_info` for browser rename | -| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads | +| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads and project visibility prefs | | Auth | Token cookie/query/header (optional on localhost) | ## Component Diagram @@ -56,6 +56,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod │ GET /api/worker-status → handleWorkerStatus │ │ GET /api/git/info / POST /api/git/rename-branch │ │ GET/POST /api/scratchpad → scratchpad (SQLite) │ +│ GET/POST /api/projects → project visibility prefs (SQLite) │ │ GET /api/sounds / GET /sounds/… (notification sounds) │ │ POST /share → handleShare (GitHub Gist) │ │ GET /events → handleEvents (SSE) │ @@ -127,7 +128,7 @@ name, while pi-web itself continues listening only on localhost. ├── session-status/ │ ├── 2026-01-15T10-30-00.000Z_a1b2c3d4.jsonl ← terminal writes here │ └── … -├── pi-web.sqlite ← scratchpads (and future local state) +├── pi-web.sqlite ← scratchpads + project visibility prefs └── pi-web/ ├── pi-web-state.json ← server state file ├── custom-themes.css ← optional user custom theme @@ -135,6 +136,27 @@ name, while pi-web itself continues listening only on localhost. └── push-subs.json ← web-push subscriptions (when push enabled) ``` +## Project Visibility + +Project filtering is an **opt-in master switch**, stored in the `app_settings` +SQLite table (`project_filter_enabled`, default **off**). Per-project enable +state lives in the `project_prefs` table. Both are server-side, so they sync +across devices. See `internal/server/projects.go`. + +- **Filter off (default):** every session shows; new sessions (web- or + terminal-created) appear immediately, exactly like before the feature existed. +- **Filter on:** the index only renders sessions whose project is **enabled** — + an allowlist. Projects discovered after the table is first seeded default to + hidden, so one-off folders stay out of view. +- **First seed** (empty `project_prefs`): every discovered project is enabled, so + turning the filter on doesn't blank the homepage. +- **Registering** a folder path (`action: register`) pre-approves it so sessions + that later land there show immediately, even before any session exists. +- Filtering is applied server-side in both `handleIndex` and `handleApiSessions` + (no client flash) and is a no-op while the master switch is off. Manage via the + index menu → **Manage Projects** (search, select/deselect-all, register, and the + filter switch), backed by `GET/POST /api/projects`. + ## Startup Order 1. Parse CLI flags (`-p`, `-host`, `-o`, `-insecure`, `-version`) diff --git a/internal/server/btw.go b/internal/server/btw.go new file mode 100644 index 0000000..76abb65 --- /dev/null +++ b/internal/server/btw.go @@ -0,0 +1,106 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "os" + + "pi-web/internal/sessions" +) + +// settingBtwSessionID is the app_settings key holding the id of the single, +// global "btw" scratch-chat session surfaced in the floating btw window. +const settingBtwSessionID = "btw_session_id" + +func (s *Server) getBtwSessionID() string { + if s.db == nil { + return "" + } + var v string + if err := s.db.QueryRow("SELECT value FROM app_settings WHERE key = ?", settingBtwSessionID).Scan(&v); err != nil { + return "" + } + return v +} + +func (s *Server) setBtwSessionID(id string) { + if s.db == nil { + return + } + prev := s.getBtwSessionID() + _, _ = s.db.Exec(`INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value`, settingBtwSessionID, id) + // Notify every connected client (on any device) so an open btw window can + // switch to the new session in realtime. Only when it actually changed. + if id != prev { + s.broadcastBtwChanged(id) + } +} + +// broadcastBtwChanged tells all clients which session is now the global btw +// session. Sent on the global topic so any open btw window re-syncs even if it +// is currently subscribed to a different (or no) session. +func (s *Server) broadcastBtwChanged(id string) { + msg, err := formatSSEJSONEvent("btw-changed", map[string]string{"sessionId": id}) + if err != nil { + return + } + s.broadcast(globalSessID, msg) +} + +// handleGetBtw returns the current global btw session id, clearing the stored +// pointer if the session file has since been deleted so the client can fall +// back to its empty state. +func (s *Server) handleGetBtw(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + id := s.getBtwSessionID() + if id != "" { + if _, err := sessions.ResolveByID(s.sessionsDir, id); err != nil { + id = "" + s.setBtwSessionID("") + } + } + writeJSON(w, http.StatusOK, map[string]any{"sessionId": id}) +} + +// handleNewBtw creates a fresh session, records it as the global btw session, +// and returns its id. The path defaults to the user's home directory when the +// caller does not supply one (e.g. the originating page had no cwd). +func (s *Server) handleNewBtw(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var body struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json body") + return + } + path := body.Path + if path == "" { + path, _ = os.UserHomeDir() + } + + id, err := sessions.CreateSessionFileWithSettings(s.sessionsDir, path, sessions.InitialSettings{}) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + s.setBtwSessionID(id) + + // Pre-warm a worker so the first chat message lands quickly, mirroring + // handleNewSession. + if s.chatSender != nil { + if resolved, err := sessions.ResolveByID(s.sessionsDir, id); err == nil { + go s.initializeNewSessionWorker(context.Background(), resolved.Session.ID, resolved.Path, sessions.InitialSettings{}) + } + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) +} diff --git a/internal/server/btw_test.go b/internal/server/btw_test.go new file mode 100644 index 0000000..3b1a0a4 --- /dev/null +++ b/internal/server/btw_test.go @@ -0,0 +1,128 @@ +package server + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + _ "modernc.org/sqlite" +) + +func newAppSettingsDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + if _, err := db.Exec(appSettingsSchema); err != nil { + t.Fatalf("failed to create app_settings table: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestBtwSessionIDRoundTrip(t *testing.T) { + db := newAppSettingsDB(t) + s := &Server{db: db} + + if got := s.getBtwSessionID(); got != "" { + t.Fatalf("expected empty id initially, got %q", got) + } + s.setBtwSessionID("abc.jsonl") + if got := s.getBtwSessionID(); got != "abc.jsonl" { + t.Fatalf("expected 'abc.jsonl', got %q", got) + } + // Upsert overwrites. + s.setBtwSessionID("def.jsonl") + if got := s.getBtwSessionID(); got != "def.jsonl" { + t.Fatalf("expected 'def.jsonl', got %q", got) + } +} + +func TestHandleNewBtwThenGet(t *testing.T) { + db := newAppSettingsDB(t) + dir := t.TempDir() + s := &Server{db: db, sessionsDir: dir} + + // Create a new btw session rooted at a real directory. + body := bytes.NewBufferString(`{"path":"` + dir + `"}`) + req := httptest.NewRequest(http.MethodPost, "/api/btw/new", body) + w := httptest.NewRecorder() + s.handleNewBtw(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + var created map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + id, _ := created["id"].(string) + if id == "" { + t.Fatalf("expected a new session id, got %v", created) + } + if stored := s.getBtwSessionID(); stored != id { + t.Fatalf("expected stored btw id %q, got %q", id, stored) + } + + // GET returns the same id, since the session file now exists. + greq := httptest.NewRequest(http.MethodGet, "/api/btw", nil) + gw := httptest.NewRecorder() + s.handleGetBtw(gw, greq) + if gw.Code != http.StatusOK { + t.Fatalf("expected 200 OK on get, got %d", gw.Code) + } + var got map[string]any + if err := json.Unmarshal(gw.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["sessionId"] != id { + t.Fatalf("expected sessionId %q, got %v", id, got["sessionId"]) + } +} + +func TestHandleGetBtwClearsStalePointer(t *testing.T) { + db := newAppSettingsDB(t) + s := &Server{db: db, sessionsDir: t.TempDir()} + + // Point at a session that does not exist on disk. + s.setBtwSessionID("2026-01-01T00-00-00.000Z_deadbeef.jsonl") + + req := httptest.NewRequest(http.MethodGet, "/api/btw", nil) + w := httptest.NewRecorder() + s.handleGetBtw(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } + var got map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["sessionId"] != "" { + t.Fatalf("expected empty sessionId for stale pointer, got %v", got["sessionId"]) + } + if stored := s.getBtwSessionID(); stored != "" { + t.Fatalf("expected stale pointer cleared, still have %q", stored) + } +} + +func TestHandleBtwMethodGuards(t *testing.T) { + s := &Server{db: newAppSettingsDB(t), sessionsDir: t.TempDir()} + + // GET handler rejects POST. + w := httptest.NewRecorder() + s.handleGetBtw(w, httptest.NewRequest(http.MethodPost, "/api/btw", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405 for POST on GET handler, got %d", w.Code) + } + + // new handler rejects GET. + w2 := httptest.NewRecorder() + s.handleNewBtw(w2, httptest.NewRequest(http.MethodGet, "/api/btw/new", nil)) + if w2.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405 for GET on new handler, got %d", w2.Code) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 901ce7b..61655e0 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -29,6 +29,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), 500) return } + summaries = s.filterEnabledSummaries(summaries) sessions.SortSummariesByActivity(summaries) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.renderIndex(w, summaries); err != nil { @@ -169,6 +170,8 @@ func (s *Server) handleApiSessions(w http.ResponseWriter, r *http.Request) { } } summaries = filtered + } else { + summaries = s.filterEnabledSummaries(summaries) } sessions.SortSummariesByActivity(summaries) diff --git a/internal/server/projects.go b/internal/server/projects.go new file mode 100644 index 0000000..71be3d0 --- /dev/null +++ b/internal/server/projects.go @@ -0,0 +1,353 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "pi-web/internal/sessions" +) + +var ( + errEmptyPath = errors.New("path is required") + errRelativePath = errors.New("path must be absolute") +) + +// projectPrefsSchema creates the table that records which projects are shown on +// the index page. A project is "enabled" when it should appear; new projects +// discovered after the first run default to disabled (allowlist), while the +// very first run seeds every existing project as enabled so the homepage looks +// unchanged until the user starts curating. +const projectPrefsSchema = `CREATE TABLE IF NOT EXISTS project_prefs ( + project_path TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'discovered', + updated_at DATETIME +)` + +// appSettingsSchema holds simple key/value app preferences. Currently only the +// project-filter master switch. +const appSettingsSchema = `CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT +)` + +const settingProjectFilterEnabled = "project_filter_enabled" + +// projectFilterEnabled reports whether the homepage should be filtered to only +// enabled projects. Off by default: with the filter off every project (and any +// new session) shows up normally. +func (s *Server) projectFilterEnabled() bool { + if s.db == nil { + return false + } + var v string + if err := s.db.QueryRow("SELECT value FROM app_settings WHERE key = ?", settingProjectFilterEnabled).Scan(&v); err != nil { + return false + } + return v == "1" +} + +func (s *Server) setProjectFilterEnabled(enabled bool) { + if s.db == nil { + return + } + v := "0" + if enabled { + v = "1" + } + _, _ = s.db.Exec(`INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value`, settingProjectFilterEnabled, v) +} + +type projectEntry struct { + Path string `json:"path"` + Enabled bool `json:"enabled"` + SessionCount int `json:"sessionCount"` + Source string `json:"source"` +} + +func (s *Server) nowTime() time.Time { + if s.now != nil { + return s.now() + } + return time.Now() +} + +// distinctProjects returns the unique, non-empty project paths in first-seen +// order. +func distinctProjects(summaries []sessions.SessionSummary) []string { + seen := make(map[string]bool) + out := make([]string, 0) + for _, sum := range summaries { + if sum.Project == "" || seen[sum.Project] { + continue + } + seen[sum.Project] = true + out = append(out, sum.Project) + } + return out +} + +// syncProjectPrefs records any not-yet-tracked discovered projects. On the very +// first run (empty table) every discovered project is enabled; afterwards new +// projects are inserted disabled so they stay hidden until the user enables +// them. Existing rows are never modified. +func (s *Server) syncProjectPrefs(discovered []string) { + if s.db == nil || len(discovered) == 0 { + return + } + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM project_prefs").Scan(&count); err != nil { + return + } + defaultEnabled := 0 + if count == 0 { + defaultEnabled = 1 + } + now := s.nowTime() + for _, p := range discovered { + if p == "" { + continue + } + _, _ = s.db.Exec(`INSERT INTO project_prefs (project_path, enabled, source, updated_at) + VALUES (?, ?, 'discovered', ?) + ON CONFLICT(project_path) DO NOTHING`, p, defaultEnabled, now) + } +} + +// enabledProjectSet returns the set of enabled project paths. The second return +// value is false when preferences are unavailable (no database), in which case +// callers should treat every project as enabled. +func (s *Server) enabledProjectSet() (map[string]bool, bool) { + if s.db == nil { + return nil, false + } + rows, err := s.db.Query("SELECT project_path FROM project_prefs WHERE enabled = 1") + if err != nil { + return nil, false + } + defer rows.Close() + set := make(map[string]bool) + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, false + } + set[p] = true + } + return set, true +} + +// filterEnabledSummaries drops sessions whose project is disabled. Sessions with +// an empty project are always kept. With no database it is a no-op. +func (s *Server) filterEnabledSummaries(summaries []sessions.SessionSummary) []sessions.SessionSummary { + if s.db == nil || !s.projectFilterEnabled() { + return summaries + } + s.syncProjectPrefs(distinctProjects(summaries)) + enabled, ok := s.enabledProjectSet() + if !ok { + return summaries + } + out := make([]sessions.SessionSummary, 0, len(summaries)) + for _, sum := range summaries { + if sum.Project == "" || enabled[sum.Project] { + out = append(out, sum) + } + } + return out +} + +func (s *Server) handleApiProjects(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + summaries, err := s.loadSummaries() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + counts := make(map[string]int) + for _, sum := range summaries { + if sum.Project != "" { + counts[sum.Project]++ + } + } + s.syncProjectPrefs(distinctProjects(summaries)) + + enabled := make(map[string]bool) + source := make(map[string]string) + if s.db != nil { + rows, err := s.db.Query("SELECT project_path, enabled, source FROM project_prefs") + if err == nil { + defer rows.Close() + for rows.Next() { + var p, src string + var en int + if err := rows.Scan(&p, &en, &src); err != nil { + continue + } + enabled[p] = en == 1 + source[p] = src + } + } + } + + // Union of projects that have sessions and projects recorded in prefs + // (e.g. registered paths without sessions yet). + paths := make(map[string]bool) + for p := range counts { + paths[p] = true + } + for p := range enabled { + paths[p] = true + } + + entries := make([]projectEntry, 0, len(paths)) + for p := range paths { + src := source[p] + if src == "" { + src = "discovered" + } + en := enabled[p] + // Without a database we cannot persist prefs; report everything enabled. + if s.db == nil { + en = true + } + entries = append(entries, projectEntry{ + Path: p, + Enabled: en, + SessionCount: counts[p], + Source: src, + }) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].SessionCount != entries[j].SessionCount { + return entries[i].SessionCount > entries[j].SessionCount + } + return entries[i].Path < entries[j].Path + }) + + writeJSON(w, 0, map[string]any{ + "projects": entries, + "filterEnabled": s.projectFilterEnabled(), + }) +} + +func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var body struct { + Path string `json:"path"` + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json body") + return + } + if s.db == nil { + writeJSONError(w, http.StatusInternalServerError, "preferences are unavailable") + return + } + + if body.Action == "enable-filter" || body.Action == "disable-filter" { + s.setProjectFilterEnabled(body.Action == "enable-filter") + writeJSON(w, 0, map[string]any{"ok": true, "filterEnabled": s.projectFilterEnabled()}) + return + } + + if body.Action == "enable-all" || body.Action == "disable-all" { + s.setAllProjectsEnabled(body.Action == "enable-all") + writeJSON(w, 0, map[string]any{"ok": true}) + return + } + + path := body.Path + if body.Action == "register" { + normalized, err := normalizeProjectPath(path) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + path = normalized + } + if strings.TrimSpace(path) == "" { + writeJSONError(w, http.StatusBadRequest, "path is required") + return + } + + now := s.nowTime() + var err error + switch body.Action { + case "enable": + _, err = s.db.Exec(`INSERT INTO project_prefs (project_path, enabled, source, updated_at) + VALUES (?, 1, 'discovered', ?) + ON CONFLICT(project_path) DO UPDATE SET enabled=1, updated_at=excluded.updated_at`, path, now) + case "disable": + _, err = s.db.Exec(`INSERT INTO project_prefs (project_path, enabled, source, updated_at) + VALUES (?, 0, 'discovered', ?) + ON CONFLICT(project_path) DO UPDATE SET enabled=0, updated_at=excluded.updated_at`, path, now) + case "register": + _, err = s.db.Exec(`INSERT INTO project_prefs (project_path, enabled, source, updated_at) + VALUES (?, 1, 'registered', ?) + ON CONFLICT(project_path) DO UPDATE SET enabled=1, updated_at=excluded.updated_at`, path, now) + case "remove": + _, err = s.db.Exec("DELETE FROM project_prefs WHERE project_path = ?", path) + default: + writeJSONError(w, http.StatusBadRequest, "unknown action") + return + } + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to update project: "+err.Error()) + return + } + writeJSON(w, 0, map[string]any{"ok": true, "path": path}) +} + +// setAllProjectsEnabled flips every known project (discovered ∪ registered) to +// enabled or disabled in one shot. Discovered projects are synced first so they +// exist as rows before the bulk update. +func (s *Server) setAllProjectsEnabled(enabled bool) { + if s.db == nil { + return + } + if summaries, err := s.loadSummaries(); err == nil { + s.syncProjectPrefs(distinctProjects(summaries)) + } + val := 0 + if enabled { + val = 1 + } + _, _ = s.db.Exec("UPDATE project_prefs SET enabled = ?, updated_at = ?", val, s.nowTime()) +} + +// normalizeProjectPath expands a leading ~ and cleans the path so a registered +// project matches the cwd recorded in future session headers. +func normalizeProjectPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", errEmptyPath + } + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path = filepath.Join(home, path[2:]) + } + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + return "", errRelativePath + } + return path, nil +} diff --git a/internal/server/projects_test.go b/internal/server/projects_test.go new file mode 100644 index 0000000..cdeed49 --- /dev/null +++ b/internal/server/projects_test.go @@ -0,0 +1,302 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "pi-web/internal/sessions" + + _ "modernc.org/sqlite" +) + +func newProjectPrefsDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + if _, err := db.Exec(projectPrefsSchema); err != nil { + t.Fatalf("create project_prefs: %v", err) + } + if _, err := db.Exec(appSettingsSchema); err != nil { + t.Fatalf("create app_settings: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func enabledSet(t *testing.T, s *Server) map[string]bool { + t.Helper() + set, ok := s.enabledProjectSet() + if !ok { + t.Fatal("enabledProjectSet not available") + } + return set +} + +func TestSyncProjectPrefs_FirstRunEnablesAll(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + s.syncProjectPrefs([]string{"/a", "/b"}) + + set := enabledSet(t, s) + if !set["/a"] || !set["/b"] { + t.Fatalf("first run should enable all discovered projects, got %v", set) + } +} + +func TestSyncProjectPrefs_NewProjectsHiddenAfterBootstrap(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + s.syncProjectPrefs([]string{"/a"}) // bootstrap: /a enabled + + s.syncProjectPrefs([]string{"/a", "/c"}) // /c appears later + set := enabledSet(t, s) + if !set["/a"] { + t.Fatal("/a should remain enabled") + } + if set["/c"] { + t.Fatal("/c discovered after bootstrap should be hidden by default") + } +} + +func TestSyncProjectPrefs_PreservesExistingState(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + s.syncProjectPrefs([]string{"/a"}) + // User disables /a. + if _, err := s.db.Exec("UPDATE project_prefs SET enabled = 0 WHERE project_path = ?", "/a"); err != nil { + t.Fatal(err) + } + // A later sync must not re-enable it. + s.syncProjectPrefs([]string{"/a", "/b"}) + set := enabledSet(t, s) + if set["/a"] { + t.Fatal("/a should stay disabled across syncs") + } +} + +func TestFilterEnabledSummaries(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + s.setProjectFilterEnabled(true) + // Seed: /a enabled, /b disabled. + s.syncProjectPrefs([]string{"/a"}) + s.syncProjectPrefs([]string{"/a", "/b"}) // /b hidden + + summaries := []sessions.SessionSummary{ + {ID: "1", Project: "/a"}, + {ID: "2", Project: "/b"}, + {ID: "3", Project: ""}, // empty project always kept + } + out := s.filterEnabledSummaries(summaries) + got := map[string]bool{} + for _, sum := range out { + got[sum.ID] = true + } + if !got["1"] || got["2"] || !got["3"] { + t.Fatalf("unexpected filter result: %v", got) + } +} + +func TestFilterEnabledSummaries_NoDBIsNoOp(t *testing.T) { + s := &Server{} + summaries := []sessions.SessionSummary{{ID: "1", Project: "/a"}} + if len(s.filterEnabledSummaries(summaries)) != 1 { + t.Fatal("without db, filtering should be a no-op") + } +} + +func TestFilterDisabledShowsEverything(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + // /b is disabled in prefs, but the master filter is off (default). + s.syncProjectPrefs([]string{"/a"}) + s.syncProjectPrefs([]string{"/a", "/b"}) + + summaries := []sessions.SessionSummary{{ID: "1", Project: "/a"}, {ID: "2", Project: "/b"}} + if got := s.filterEnabledSummaries(summaries); len(got) != 2 { + t.Fatalf("filter off should show everything, got %d", len(got)) + } + + s.setProjectFilterEnabled(true) + if got := s.filterEnabledSummaries(summaries); len(got) != 1 { + t.Fatalf("filter on should hide /b, got %d", len(got)) + } +} + +func TestHandleUpdateProject_FilterToggle(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + if s.projectFilterEnabled() { + t.Fatal("filter should default off") + } + + post := func(action string) { + body, _ := json.Marshal(map[string]string{"action": action}) + req := httptest.NewRequest(http.MethodPost, "/api/projects", strings.NewReader(string(body))) + w := httptest.NewRecorder() + s.handleUpdateProject(w, req) + if w.Code != http.StatusOK { + t.Fatalf("%s status = %d", action, w.Code) + } + } + + post("enable-filter") + if !s.projectFilterEnabled() { + t.Fatal("enable-filter should turn the filter on") + } + post("disable-filter") + if s.projectFilterEnabled() { + t.Fatal("disable-filter should turn the filter off") + } +} + +func TestHandleUpdateProject(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + + post := func(path, action string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"path": path, "action": action}) + req := httptest.NewRequest(http.MethodPost, "/api/projects", strings.NewReader(string(body))) + w := httptest.NewRecorder() + s.handleUpdateProject(w, req) + return w + } + + if w := post("/a", "enable"); w.Code != http.StatusOK { + t.Fatalf("enable status = %d", w.Code) + } + if !enabledSet(t, s)["/a"] { + t.Fatal("/a should be enabled") + } + + if w := post("/a", "disable"); w.Code != http.StatusOK { + t.Fatalf("disable status = %d", w.Code) + } + if enabledSet(t, s)["/a"] { + t.Fatal("/a should be disabled") + } + + // register stores the path with source=registered and enabled. + if w := post("/home/user/proj", "register"); w.Code != http.StatusOK { + t.Fatalf("register status = %d", w.Code) + } + var source string + if err := s.db.QueryRow("SELECT source FROM project_prefs WHERE project_path = ?", "/home/user/proj").Scan(&source); err != nil { + t.Fatal(err) + } + if source != "registered" { + t.Fatalf("source = %q, want registered", source) + } + + // remove deletes the row. + if w := post("/home/user/proj", "remove"); w.Code != http.StatusOK { + t.Fatalf("remove status = %d", w.Code) + } + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM project_prefs WHERE project_path = ?", "/home/user/proj").Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("removed project should be gone") + } + + if w := post("/a", "bogus"); w.Code != http.StatusBadRequest { + t.Fatalf("unknown action status = %d, want 400", w.Code) + } +} + +func TestHandleUpdateProject_BulkToggle(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t), cache: sessions.NewCache(), sessionsDir: t.TempDir()} + s.syncProjectPrefs([]string{"/a", "/b", "/c"}) + + post := func(action string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"action": action}) + req := httptest.NewRequest(http.MethodPost, "/api/projects", strings.NewReader(string(body))) + w := httptest.NewRecorder() + s.handleUpdateProject(w, req) + return w + } + + if w := post("disable-all"); w.Code != http.StatusOK { + t.Fatalf("disable-all status = %d", w.Code) + } + if len(enabledSet(t, s)) != 0 { + t.Fatal("disable-all should disable every project") + } + + if w := post("enable-all"); w.Code != http.StatusOK { + t.Fatalf("enable-all status = %d", w.Code) + } + set := enabledSet(t, s) + if !set["/a"] || !set["/b"] || !set["/c"] { + t.Fatalf("enable-all should enable every project, got %v", set) + } +} + +func TestHandleApiProjects(t *testing.T) { + sessionsDir := t.TempDir() + writeSessionWithCWD(t, filepath.Join(sessionsDir, "sub1"), "a.jsonl", "/home/user/project-a") + writeSessionWithCWD(t, filepath.Join(sessionsDir, "sub1"), "b.jsonl", "/home/user/project-a") + writeSessionWithCWD(t, filepath.Join(sessionsDir, "sub2"), "c.jsonl", "/home/user/project-b") + + s := &Server{db: newProjectPrefsDB(t), sessionsDir: sessionsDir, cache: sessions.NewCache()} + + req := httptest.NewRequest(http.MethodGet, "/api/projects", nil) + w := httptest.NewRecorder() + s.handleApiProjects(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + + var payload struct { + Projects []projectEntry `json:"projects"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + byPath := map[string]projectEntry{} + for _, p := range payload.Projects { + byPath[p.Path] = p + } + a, ok := byPath["/home/user/project-a"] + if !ok || a.SessionCount != 2 || !a.Enabled { + t.Fatalf("project-a entry wrong: %+v", a) + } + b, ok := byPath["/home/user/project-b"] + if !ok || b.SessionCount != 1 || !b.Enabled { + t.Fatalf("project-b entry wrong: %+v", b) + } +} + +func TestNormalizeProjectPath(t *testing.T) { + home, _ := os.UserHomeDir() + cases := []struct { + in string + want string + wantErr bool + }{ + {"/abs/path", "/abs/path", false}, + {"/abs/path/", "/abs/path", false}, + {"~/proj", filepath.Join(home, "proj"), false}, + {" /spaced ", "/spaced", false}, + {"relative/path", "", true}, + {"", "", true}, + } + for _, tc := range cases { + got, err := normalizeProjectPath(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("normalizeProjectPath(%q) expected error", tc.in) + } + continue + } + if err != nil { + t.Errorf("normalizeProjectPath(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("normalizeProjectPath(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 84c2ec6..1b1ec82 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -117,6 +117,12 @@ func New(deps Deps) *Server { if err != nil { fmt.Fprintf(os.Stderr, "failed to create scratchpads table: %v\n", err) } + if _, err := db.Exec(projectPrefsSchema); err != nil { + fmt.Fprintf(os.Stderr, "failed to create project_prefs table: %v\n", err) + } + if _, err := db.Exec(appSettingsSchema); err != nil { + fmt.Fprintf(os.Stderr, "failed to create app_settings table: %v\n", err) + } } s := &Server{ @@ -188,6 +194,13 @@ func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/api/clone-session", s.auth.Wrap(s.handleApiCloneSession)) mux.HandleFunc("/api/rename-session", s.auth.Wrap(s.handleRenameSession)) mux.HandleFunc("/api/recent-locations", s.auth.Wrap(s.handleRecentLocations)) + mux.HandleFunc("/api/projects", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + s.auth.Wrap(s.handleUpdateProject)(w, r) + } else { + s.auth.Wrap(s.handleApiProjects)(w, r) + } + }) mux.HandleFunc("/api/git/info", s.auth.Wrap(s.handleGitInfo)) mux.HandleFunc("/api/git/rename-branch", s.auth.Wrap(s.handleGitRenameBranch)) mux.HandleFunc("/custom-themes.css", s.auth.Wrap(s.handleCustomThemes)) @@ -198,6 +211,8 @@ func (s *Server) Register(mux *http.ServeMux) { s.auth.Wrap(s.handleGetScratchpad)(w, r) } }) + mux.HandleFunc("/api/btw", s.auth.Wrap(s.handleGetBtw)) + mux.HandleFunc("/api/btw/new", s.auth.Wrap(s.handleNewBtw)) if s.push != nil { s.push.Register(mux, s.auth.Wrap) } diff --git a/internal/ui/live_menu.go b/internal/ui/live_menu.go index a59f1a2..befc179 100644 --- a/internal/ui/live_menu.go +++ b/internal/ui/live_menu.go @@ -55,6 +55,7 @@ func homeMenuHTML() template.HTML { Sections: []liveMenuSection{ {Items: []liveMenuItem{ {Label: "New Session", Attrs: `data-new-session-btn role="menuitem"`}, + {Label: "Manage Projects", Attrs: `id="manage-projects-btn" data-manage-projects-btn role="menuitem"`}, {Label: "Import Session", Muted: true, Attrs: `role="menuitem"`}, }}, {Items: []liveMenuItem{ @@ -93,7 +94,6 @@ func sessionMenuHTML(id, class, bodyClass, itemClass, toggleID, themeIconClass, ItemClass: itemClass, Sections: []liveMenuSection{ {Title: "Session", Items: []liveMenuItem{ - {Label: "New Session", Suffix: template.HTML("⌘T"), Attrs: `data-action="new-session"`}, {Label: "Search Sessions", Suffix: template.HTML("⌘K"), Attrs: `data-action="list-sessions"`}, }}, {Items: []liveMenuItem{ diff --git a/internal/ui/live_templates/chat_composer.html b/internal/ui/live_templates/chat_composer.html index 8116066..0589f0a 100644 --- a/internal/ui/live_templates/chat_composer.html +++ b/internal/ui/live_templates/chat_composer.html @@ -229,6 +229,11 @@ +
+ {{ liveServiceWorkerScript }} diff --git a/internal/ui/live_templates/session.html b/internal/ui/live_templates/session.html index 7e10840..92a4ec6 100644 --- a/internal/ui/live_templates/session.html +++ b/internal/ui/live_templates/session.html @@ -13,9 +13,20 @@