This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"superwork-tui/internal/config"
)
// ManagedSession is a Claude session created via the session manager tab.
type ManagedSession struct {
ID string `json:"id"`
Name string `json:"name"`
ProfilePath string `json:"profilePath,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// ManagedSessionsData is the workspace-level managed sessions list.
type ManagedSessionsData struct {
Sessions []ManagedSession `json:"sessions"`
}
func sessionsFile(workspaceRoot string) string {
return filepath.Join(config.SpxDir(workspaceRoot), "session-names.json")
}
// ReadManagedSessions reads the managed sessions list.
// A missing or malformed file returns an empty list without error.
func ReadManagedSessions(workspaceRoot string) (ManagedSessionsData, error) {
raw, err := os.ReadFile(sessionsFile(workspaceRoot))
if err != nil {
return ManagedSessionsData{Sessions: []ManagedSession{}}, nil
}
var data ManagedSessionsData
if err := json.Unmarshal(raw, &data); err != nil {
return ManagedSessionsData{Sessions: []ManagedSession{}}, nil
}
if data.Sessions == nil {
data.Sessions = []ManagedSession{}
}
return data, nil
}
// WriteManagedSessions persists the sessions list. Creates .spx if needed.
func WriteManagedSessions(workspaceRoot string, data ManagedSessionsData) error {
if err := os.MkdirAll(config.SpxDir(workspaceRoot), 0o755); err != nil {
return fmt.Errorf("create .spx dir: %w", err)
}
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal sessions: %w", err)
}
if err := os.WriteFile(sessionsFile(workspaceRoot), append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("write sessions: %w", err)
}
return nil
}
// AddSession appends s to data and returns the new data.
func AddSession(data ManagedSessionsData, s ManagedSession) ManagedSessionsData {
out := make([]ManagedSession, len(data.Sessions), len(data.Sessions)+1)
copy(out, data.Sessions)
return ManagedSessionsData{Sessions: append(out, s)}
}
// RenameSession sets the Name of the session with the given id.
func RenameSession(data ManagedSessionsData, id, name string) ManagedSessionsData {
out := make([]ManagedSession, len(data.Sessions))
copy(out, data.Sessions)
for i := range out {
if out[i].ID == id {
out[i].Name = name
break
}
}
return ManagedSessionsData{Sessions: out}
}
// DeleteSession removes the session with the given id.
func DeleteSession(data ManagedSessionsData, id string) ManagedSessionsData {
out := make([]ManagedSession, 0, len(data.Sessions))
for _, s := range data.Sessions {
if s.ID != id {
out = append(out, s)
}
}
data.Sessions = out
return data
}
// FindSession returns the session with the given id, if present.
func FindSession(data ManagedSessionsData, id string) (ManagedSession, bool) {
for _, s := range data.Sessions {
if s.ID == id {
return s, true
}
}
return ManagedSession{}, false
}
+140
View File
@@ -0,0 +1,140 @@
package store
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestReadManagedSessions_missingFile(t *testing.T) {
dir := t.TempDir()
data, err := ReadManagedSessions(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data.Sessions == nil {
t.Error("Sessions should be non-nil empty slice, got nil")
}
if len(data.Sessions) != 0 {
t.Errorf("want 0 sessions, got %d", len(data.Sessions))
}
}
func TestManagedSessions_roundtrip(t *testing.T) {
dir := t.TempDir()
original := ManagedSessionsData{
Sessions: []ManagedSession{
{ID: "s1", Name: "Session One", ProfilePath: "/path/to/profile.json", CreatedAt: 1000},
{ID: "s2", Name: "Session Two", ProfilePath: "", CreatedAt: 2000},
},
}
if err := WriteManagedSessions(dir, original); err != nil {
t.Fatalf("WriteManagedSessions: %v", err)
}
got, err := ReadManagedSessions(dir)
if err != nil {
t.Fatalf("ReadManagedSessions: %v", err)
}
if !reflect.DeepEqual(got, original) {
t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, original)
}
}
func TestWriteManagedSessions_createsSpxDir(t *testing.T) {
dir := t.TempDir()
data := ManagedSessionsData{Sessions: []ManagedSession{}}
if err := WriteManagedSessions(dir, data); err != nil {
t.Fatalf("WriteManagedSessions: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".spx", "session-names.json")); err != nil {
t.Errorf("session-names.json not created: %v", err)
}
}
func TestAddSession(t *testing.T) {
data := ManagedSessionsData{Sessions: []ManagedSession{}}
s := ManagedSession{ID: "a1", Name: "Alpha", CreatedAt: 100}
data = AddSession(data, s)
if len(data.Sessions) != 1 {
t.Fatalf("want 1 session, got %d", len(data.Sessions))
}
if data.Sessions[0] != s {
t.Errorf("session mismatch: got %+v, want %+v", data.Sessions[0], s)
}
}
func TestRenameSession(t *testing.T) {
data := ManagedSessionsData{Sessions: []ManagedSession{
{ID: "a1", Name: "Old Name", CreatedAt: 100},
{ID: "a2", Name: "Other", CreatedAt: 200},
}}
data = RenameSession(data, "a1", "New Name")
if data.Sessions[0].Name != "New Name" {
t.Errorf("want 'New Name', got %q", data.Sessions[0].Name)
}
// other session unchanged
if data.Sessions[1].Name != "Other" {
t.Errorf("other session name changed: got %q", data.Sessions[1].Name)
}
}
func TestRenameSession_notFound(t *testing.T) {
data := ManagedSessionsData{Sessions: []ManagedSession{
{ID: "a1", Name: "Alpha", CreatedAt: 100},
}}
result := RenameSession(data, "nonexistent", "X")
if result.Sessions[0].Name != "Alpha" {
t.Error("renaming nonexistent id should not change data")
}
}
func TestDeleteSession(t *testing.T) {
data := ManagedSessionsData{Sessions: []ManagedSession{
{ID: "a1", Name: "Alpha", CreatedAt: 100},
{ID: "a2", Name: "Beta", CreatedAt: 200},
{ID: "a3", Name: "Gamma", CreatedAt: 300},
}}
data = DeleteSession(data, "a2")
if len(data.Sessions) != 2 {
t.Fatalf("want 2 sessions after delete, got %d", len(data.Sessions))
}
for _, s := range data.Sessions {
if s.ID == "a2" {
t.Error("deleted session still present")
}
}
}
func TestDeleteSession_notFound(t *testing.T) {
data := ManagedSessionsData{Sessions: []ManagedSession{
{ID: "a1", Name: "Alpha", CreatedAt: 100},
}}
result := DeleteSession(data, "nonexistent")
if len(result.Sessions) != 1 {
t.Error("deleting nonexistent id should not change session count")
}
}
func TestFindSession(t *testing.T) {
s := ManagedSession{ID: "a1", Name: "Alpha", CreatedAt: 100}
data := ManagedSessionsData{Sessions: []ManagedSession{s}}
found, ok := FindSession(data, "a1")
if !ok {
t.Fatal("expected to find session a1")
}
if found != s {
t.Errorf("found session mismatch: got %+v, want %+v", found, s)
}
}
func TestFindSession_notFound(t *testing.T) {
data := ManagedSessionsData{Sessions: []ManagedSession{
{ID: "a1", Name: "Alpha", CreatedAt: 100},
}}
_, ok := FindSession(data, "nope")
if ok {
t.Error("expected not found")
}
}
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"superwork-tui/internal/config"
)
// ProfileRow is one row in the profiles grid: a key and its per-profile values.
type ProfileRow struct {
Key string `json:"key"`
Values map[string]string `json:"values"`
}
// ProfilesData is the workspace-level profiles grid persisted to profiles.json.
type ProfilesData struct {
Profiles []string `json:"profiles"`
Rows []ProfileRow `json:"rows"`
}
func profilesFile(workspaceRoot string) string {
return filepath.Join(config.SpxDir(workspaceRoot), "profiles.json")
}
// ReadProfiles reads the workspace profiles grid.
// A missing or malformed file returns default data without error.
func ReadProfiles(workspaceRoot string) (ProfilesData, error) {
raw, err := os.ReadFile(profilesFile(workspaceRoot))
if err != nil {
return ProfilesData{
Profiles: []string{"dev", "prod"},
Rows: []ProfileRow{},
}, nil
}
var data ProfilesData
if err := json.Unmarshal(raw, &data); err != nil {
return ProfilesData{
Profiles: []string{"dev", "prod"},
Rows: []ProfileRow{},
}, nil
}
if len(data.Profiles) == 0 {
data.Profiles = []string{"dev", "prod"}
}
if data.Rows == nil {
data.Rows = []ProfileRow{}
}
return data, nil
}
// WriteProfiles persists the profiles grid. Creates .spx if needed.
func WriteProfiles(workspaceRoot string, data ProfilesData) error {
if err := os.MkdirAll(config.SpxDir(workspaceRoot), 0o755); err != nil {
return fmt.Errorf("create .spx dir: %w", err)
}
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal profiles: %w", err)
}
if err := os.WriteFile(profilesFile(workspaceRoot), append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("write profiles: %w", err)
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestReadProfiles_missingFile(t *testing.T) {
dir := t.TempDir()
data, err := ReadProfiles(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantProfiles := []string{"dev", "prod"}
if !reflect.DeepEqual(data.Profiles, wantProfiles) {
t.Errorf("Profiles = %v, want %v", data.Profiles, wantProfiles)
}
if len(data.Rows) != 0 {
t.Errorf("Rows should be empty, got %v", data.Rows)
}
}
func TestProfiles_roundtrip(t *testing.T) {
dir := t.TempDir()
original := ProfilesData{
Profiles: []string{"staging", "prod", "dev"},
Rows: []ProfileRow{
{Key: "DB_URL", Values: map[string]string{"dev": "localhost", "prod": "prod.db"}},
{Key: "API_KEY", Values: map[string]string{"dev": "dev-key", "staging": "stage-key"}},
},
}
if err := WriteProfiles(dir, original); err != nil {
t.Fatalf("WriteProfiles: %v", err)
}
got, err := ReadProfiles(dir)
if err != nil {
t.Fatalf("ReadProfiles: %v", err)
}
if !reflect.DeepEqual(got, original) {
t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, original)
}
}
func TestProfiles_jsonTags(t *testing.T) {
dir := t.TempDir()
spxDir := filepath.Join(dir, ".spx")
if err := os.MkdirAll(spxDir, 0o755); err != nil {
t.Fatal(err)
}
raw := `{"profiles":["x"],"rows":[{"key":"K","values":{"x":"v"}}]}` + "\n"
if err := os.WriteFile(filepath.Join(spxDir, "profiles.json"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
data, err := ReadProfiles(dir)
if err != nil {
t.Fatalf("ReadProfiles: %v", err)
}
if len(data.Profiles) != 1 || data.Profiles[0] != "x" {
t.Errorf("Profiles = %v, want [x]", data.Profiles)
}
if len(data.Rows) != 1 || data.Rows[0].Key != "K" || data.Rows[0].Values["x"] != "v" {
t.Errorf("Rows = %v, unexpected", data.Rows)
}
// verify written JSON uses lowercase tags
if err := WriteProfiles(dir, data); err != nil {
t.Fatal(err)
}
written, _ := os.ReadFile(filepath.Join(spxDir, "profiles.json"))
var m map[string]any
if err := json.Unmarshal(written, &m); err != nil {
t.Fatal(err)
}
if _, ok := m["profiles"]; !ok {
t.Error("written JSON missing 'profiles' key")
}
if _, ok := m["rows"]; !ok {
t.Error("written JSON missing 'rows' key")
}
}
func TestWriteProfiles_createsSpxDir(t *testing.T) {
dir := t.TempDir()
// .spx does not exist yet
if err := WriteProfiles(dir, ProfilesData{Profiles: []string{"a"}, Rows: nil}); err != nil {
t.Fatalf("WriteProfiles: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".spx", "profiles.json")); err != nil {
t.Errorf("profiles.json not created: %v", err)
}
}
+97
View File
@@ -0,0 +1,97 @@
// Package store persists workspace-level PR review confirmation state.
//
// Confirmations live in <workspace>/.spx/pr-review-confirmed.json as a
// map[string][]string. File-level confirmations use key "<issueNumber>:<sha>"
// and hold the confirmed file paths of that commit; commit-level confirmations
// use key "<issueNumber>:commits" and hold the confirmed commit SHAs. Directories
// are not tracked separately. A missing or malformed file reads as empty.
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"superwork-tui/internal/config"
)
type confirmedMap = map[string][]string
func confirmedFile(workspaceRoot string) string {
return filepath.Join(config.SpxDir(workspaceRoot), "pr-review-confirmed.json")
}
func fileKey(issueNumber int, sha string) string {
return strconv.Itoa(issueNumber) + ":" + sha
}
func commitsKey(issueNumber int) string {
return strconv.Itoa(issueNumber) + ":commits"
}
// readMap returns the whole confirmation map. A missing or unparseable file
// reads as an empty map rather than an error.
func readMap(workspaceRoot string) confirmedMap {
raw, err := os.ReadFile(confirmedFile(workspaceRoot))
if err != nil {
return confirmedMap{}
}
var parsed confirmedMap
if err := json.Unmarshal(raw, &parsed); err != nil {
return confirmedMap{}
}
return parsed
}
// writeMap persists the confirmation map, creating .spx if needed.
func writeMap(workspaceRoot string, m confirmedMap) error {
if err := os.MkdirAll(config.SpxDir(workspaceRoot), 0o755); err != nil {
return fmt.Errorf("create .spx dir: %w", err)
}
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("marshal confirmations: %w", err)
}
if err := os.WriteFile(confirmedFile(workspaceRoot), append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("write confirmations: %w", err)
}
return nil
}
// ReadConfirmed returns the confirmed file paths for a (issueNumber, sha) commit.
func ReadConfirmed(workspaceRoot string, issueNumber int, sha string) ([]string, error) {
return readMap(workspaceRoot)[fileKey(issueNumber, sha)], nil
}
// WriteConfirmed sets the confirmed file paths for a (issueNumber, sha) commit.
// An empty slice deletes the key.
func WriteConfirmed(workspaceRoot string, issueNumber int, sha string, paths []string) error {
m := readMap(workspaceRoot)
key := fileKey(issueNumber, sha)
if len(paths) > 0 {
m[key] = paths
} else {
delete(m, key)
}
return writeMap(workspaceRoot, m)
}
// ReadConfirmedCommits returns the confirmed commit SHAs for an issue.
func ReadConfirmedCommits(workspaceRoot string, issueNumber int) ([]string, error) {
return readMap(workspaceRoot)[commitsKey(issueNumber)], nil
}
// WriteConfirmedCommits sets the confirmed commit SHAs for an issue.
// An empty slice deletes the key.
func WriteConfirmedCommits(workspaceRoot string, issueNumber int, shas []string) error {
m := readMap(workspaceRoot)
key := commitsKey(issueNumber)
if len(shas) > 0 {
m[key] = shas
} else {
delete(m, key)
}
return writeMap(workspaceRoot, m)
}
+128
View File
@@ -0,0 +1,128 @@
package store_test
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
"superwork-tui/internal/store"
)
func TestReadConfirmed_MissingFile(t *testing.T) {
root := t.TempDir()
got, err := store.ReadConfirmed(root, 12, "abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestWriteConfirmed_Roundtrip(t *testing.T) {
root := t.TempDir()
want := []string{"src/a.go", "src/b.go"}
if err := store.WriteConfirmed(root, 12, "abc", want); err != nil {
t.Fatalf("write: %v", err)
}
got, err := store.ReadConfirmed(root, 12, "abc")
if err != nil {
t.Fatalf("read: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestWriteConfirmed_EmptyDeletesKey(t *testing.T) {
root := t.TempDir()
if err := store.WriteConfirmed(root, 12, "abc", []string{"x.go"}); err != nil {
t.Fatalf("write: %v", err)
}
if err := store.WriteConfirmed(root, 12, "abc", nil); err != nil {
t.Fatalf("write empty: %v", err)
}
m := readRawMap(t, root)
if _, ok := m["12:abc"]; ok {
t.Errorf("key 12:abc still present after empty write: %v", m)
}
}
func TestKeyConvention(t *testing.T) {
root := t.TempDir()
if err := store.WriteConfirmed(root, 7, "deadbeef", []string{"f.go"}); err != nil {
t.Fatalf("write file: %v", err)
}
if err := store.WriteConfirmedCommits(root, 7, []string{"deadbeef", "cafe"}); err != nil {
t.Fatalf("write commits: %v", err)
}
m := readRawMap(t, root)
if _, ok := m["7:deadbeef"]; !ok {
t.Errorf("missing file-level key 7:deadbeef: %v", m)
}
if _, ok := m["7:commits"]; !ok {
t.Errorf("missing commit-level key 7:commits: %v", m)
}
}
func TestConfirmedCommits_Roundtrip(t *testing.T) {
root := t.TempDir()
want := []string{"sha1", "sha2"}
if err := store.WriteConfirmedCommits(root, 9, want); err != nil {
t.Fatalf("write: %v", err)
}
got, err := store.ReadConfirmedCommits(root, 9)
if err != nil {
t.Fatalf("read: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestConfirmedCommits_EmptyDeletesKey(t *testing.T) {
root := t.TempDir()
if err := store.WriteConfirmedCommits(root, 9, []string{"sha1"}); err != nil {
t.Fatalf("write: %v", err)
}
if err := store.WriteConfirmedCommits(root, 9, nil); err != nil {
t.Fatalf("write empty: %v", err)
}
m := readRawMap(t, root)
if _, ok := m["9:commits"]; ok {
t.Errorf("key 9:commits still present after empty write: %v", m)
}
}
func TestReadConfirmed_MalformedFile(t *testing.T) {
root := t.TempDir()
spx := filepath.Join(root, ".spx")
if err := os.MkdirAll(spx, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(spx, "pr-review-confirmed.json"), []byte("not json"), 0o644); err != nil {
t.Fatal(err)
}
got, err := store.ReadConfirmed(root, 1, "x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("got %v, want empty for malformed file", got)
}
}
func readRawMap(t *testing.T, root string) map[string][]string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(root, ".spx", "pr-review-confirmed.json"))
if err != nil {
t.Fatalf("read raw: %v", err)
}
var m map[string][]string
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("unmarshal raw: %v", err)
}
return m
}