detached ca9fb14

feat(boundaries): add PreviewStore port and filesystem adapter

commit
ca9fb1419c920316617348d60f9fe17a60503b82
author
brice · · 2026-03-19 04:45 UTC
committer
brice · · 2026-03-19 05:13 UTC
parent
d757b58
signature
unsigned
3 files · +139 -0
A internal/adapters/out/filesystem/previewstore.go +65 -0
@@ -0,0 +1,65 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "io/fs"
8+ "os"
9+ "sync"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+type previewStoreData struct {
15+ Previews []domain.PreviewRoute `json:"previews"`
16+}
17+
18+// PreviewStore persists preview routes to a JSON file.
19+type PreviewStore struct {
20+ path string
21+ mu sync.Mutex
22+}
23+
24+// NewPreviewStore creates a new filesystem-backed preview store.
25+func NewPreviewStore(path string) *PreviewStore {
26+ return &PreviewStore{path: path}
27+}
28+
29+func (s *PreviewStore) Load(_ context.Context) ([]domain.PreviewRoute, error) {
30+ s.mu.Lock()
31+ defer s.mu.Unlock()
32+
33+ data, err := os.ReadFile(s.path)
34+ if err != nil {
35+ if errors.Is(err, fs.ErrNotExist) {
36+ return nil, nil
37+ }
38+ return nil, err
39+ }
40+
41+ var store previewStoreData
42+ if err := json.Unmarshal(data, &store); err != nil {
43+ return nil, err
44+ }
45+ return store.Previews, nil
46+}
47+
48+func (s *PreviewStore) Save(_ context.Context, previews []domain.PreviewRoute) error {
49+ s.mu.Lock()
50+ defer s.mu.Unlock()
51+
52+ if len(previews) == 0 {
53+ err := os.Remove(s.path)
54+ if errors.Is(err, fs.ErrNotExist) {
55+ return nil
56+ }
57+ return err
58+ }
59+
60+ data, err := json.MarshalIndent(previewStoreData{Previews: previews}, "", " ")
61+ if err != nil {
62+ return err
63+ }
64+ return os.WriteFile(s.path, data, 0o644)
65+}
@@ -0,0 +1,65 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "io/fs"
8+ "os"
9+ "sync"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+type previewStoreData struct {
15+ Previews []domain.PreviewRoute `json:"previews"`
16+}
17+
18+// PreviewStore persists preview routes to a JSON file.
19+type PreviewStore struct {
20+ path string
21+ mu sync.Mutex
22+}
23+
24+// NewPreviewStore creates a new filesystem-backed preview store.
25+func NewPreviewStore(path string) *PreviewStore {
26+ return &PreviewStore{path: path}
27+}
28+
29+func (s *PreviewStore) Load(_ context.Context) ([]domain.PreviewRoute, error) {
30+ s.mu.Lock()
31+ defer s.mu.Unlock()
32+
33+ data, err := os.ReadFile(s.path)
34+ if err != nil {
35+ if errors.Is(err, fs.ErrNotExist) {
36+ return nil, nil
37+ }
38+ return nil, err
39+ }
40+
41+ var store previewStoreData
42+ if err := json.Unmarshal(data, &store); err != nil {
43+ return nil, err
44+ }
45+ return store.Previews, nil
46+}
47+
48+func (s *PreviewStore) Save(_ context.Context, previews []domain.PreviewRoute) error {
49+ s.mu.Lock()
50+ defer s.mu.Unlock()
51+
52+ if len(previews) == 0 {
53+ err := os.Remove(s.path)
54+ if errors.Is(err, fs.ErrNotExist) {
55+ return nil
56+ }
57+ return err
58+ }
59+
60+ data, err := json.MarshalIndent(previewStoreData{Previews: previews}, "", " ")
61+ if err != nil {
62+ return err
63+ }
64+ return os.WriteFile(s.path, data, 0o644)
65+}
A internal/adapters/out/filesystem/previewstore_test.go +61 -0
@@ -0,0 +1,61 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestPreviewStore_SaveAndLoad(t *testing.T) {
16+ dir := t.TempDir()
17+ store := NewPreviewStore(filepath.Join(dir, "previews.json"))
18+
19+ previews := []domain.PreviewRoute{
20+ {
21+ Domain: "myapp--feat.example.com",
22+ Image: "myapp:preview-feat",
23+ BaseRoute: "myapp.example.com",
24+ Name: "feat",
25+ CreatedAt: time.Now().Truncate(time.Second),
26+ ExpiresAt: time.Now().Add(48 * time.Hour).Truncate(time.Second),
27+ HTTPS: true,
28+ Volumes: []string{"preview-feat-pgdata"},
29+ Containers: []string{"preview-feat-postgres"},
30+ },
31+ }
32+
33+ ctx := context.Background()
34+ err := store.Save(ctx, previews)
35+ require.NoError(t, err)
36+
37+ loaded, err := store.Load(ctx)
38+ require.NoError(t, err)
39+ assert.Equal(t, previews, loaded)
40+}
41+
42+func TestPreviewStore_LoadEmpty(t *testing.T) {
43+ dir := t.TempDir()
44+ store := NewPreviewStore(filepath.Join(dir, "previews.json"))
45+
46+ loaded, err := store.Load(context.Background())
47+ require.NoError(t, err)
48+ assert.Empty(t, loaded)
49+}
50+
51+func TestPreviewStore_SaveEmpty(t *testing.T) {
52+ dir := t.TempDir()
53+ path := filepath.Join(dir, "previews.json")
54+ store := NewPreviewStore(path)
55+
56+ err := store.Save(context.Background(), []domain.PreviewRoute{})
57+ require.NoError(t, err)
58+
59+ _, err = os.Stat(path)
60+ assert.True(t, os.IsNotExist(err))
61+}
@@ -0,0 +1,61 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestPreviewStore_SaveAndLoad(t *testing.T) {
16+ dir := t.TempDir()
17+ store := NewPreviewStore(filepath.Join(dir, "previews.json"))
18+
19+ previews := []domain.PreviewRoute{
20+ {
21+ Domain: "myapp--feat.example.com",
22+ Image: "myapp:preview-feat",
23+ BaseRoute: "myapp.example.com",
24+ Name: "feat",
25+ CreatedAt: time.Now().Truncate(time.Second),
26+ ExpiresAt: time.Now().Add(48 * time.Hour).Truncate(time.Second),
27+ HTTPS: true,
28+ Volumes: []string{"preview-feat-pgdata"},
29+ Containers: []string{"preview-feat-postgres"},
30+ },
31+ }
32+
33+ ctx := context.Background()
34+ err := store.Save(ctx, previews)
35+ require.NoError(t, err)
36+
37+ loaded, err := store.Load(ctx)
38+ require.NoError(t, err)
39+ assert.Equal(t, previews, loaded)
40+}
41+
42+func TestPreviewStore_LoadEmpty(t *testing.T) {
43+ dir := t.TempDir()
44+ store := NewPreviewStore(filepath.Join(dir, "previews.json"))
45+
46+ loaded, err := store.Load(context.Background())
47+ require.NoError(t, err)
48+ assert.Empty(t, loaded)
49+}
50+
51+func TestPreviewStore_SaveEmpty(t *testing.T) {
52+ dir := t.TempDir()
53+ path := filepath.Join(dir, "previews.json")
54+ store := NewPreviewStore(path)
55+
56+ err := store.Save(context.Background(), []domain.PreviewRoute{})
57+ require.NoError(t, err)
58+
59+ _, err = os.Stat(path)
60+ assert.True(t, os.IsNotExist(err))
61+}
A internal/boundaries/out/previewstore.go +13 -0
@@ -0,0 +1,13 @@
1+package out
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
8+
9+// PreviewStore persists active preview routes.
10+type PreviewStore interface {
11+ Load(ctx context.Context) ([]domain.PreviewRoute, error)
12+ Save(ctx context.Context, previews []domain.PreviewRoute) error
13+}
@@ -0,0 +1,13 @@
1+package out
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
8+
9+// PreviewStore persists active preview routes.
10+type PreviewStore interface {
11+ Load(ctx context.Context) ([]domain.PreviewRoute, error)
12+ Save(ctx context.Context, previews []domain.PreviewRoute) error
13+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close