- commit
- 92b186aef648f523a272e229177ee9d221ad7f63
- parent
- 6aed9ac7d1833d3682b605c8a179716ab5f74dac
- author
- Dami <dami.etoile@gmail.com>
| A | .github/ | 55 | ++++++++++++++++++++++++++++++ |
| A | .github/ | 37 | ++++++++++++++++++++ |
| M | .gitignore | 2 | + |
| A | .goreleaser.yaml | 35 | +++++++++++++++++++ |
| A | LICENSE | 21 | +++++++++++ |
| A | Makefile | 28 | +++++++++++++++ |
| M | internal/ | 19 | ---------- |
| A | internal/ | 28 | +++++++++++++++ |
| M | internal/ | 39 | ++++++++++++++++++++-- |
| M | internal/ | 9 | ----- |
| M | internal/ | 20 | +++++++++++ |
| M | main.go | 18 | +++++++++- |
@@ -0,0 +1,55 @@
1+name: CI2+3+on:4+ push:5+ branches: [main]6+ pull_request:7+ workflow_dispatch:8+9+permissions:10+ contents: read11+12+concurrency:13+ group: ci-${{ github.workflow }}-${{ github.ref }}14+ cancel-in-progress: true15+16+jobs:17+ check:18+ name: Format, vet, lint, test (${{ matrix.os }})19+ strategy:20+ fail-fast: false21+ matrix:22+ os: [ubuntu-24.04, macos-15]23+ runs-on: ${{ matrix.os }}24+ timeout-minutes: 1525+ steps:26+ - name: Check out source27+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.128+ - name: Set up Go29+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.030+ with:31+ go-version-file: go.mod32+ cache: true33+ - name: Check34+ run: make check35+ - name: Build36+ run: make build37+38+ cross-build:39+ name: Cross-compile release targets40+ runs-on: ubuntu-24.0441+ timeout-minutes: 1042+ steps:43+ - name: Check out source44+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.145+ - name: Set up Go46+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.047+ with:48+ go-version-file: go.mod49+ cache: true50+ - name: Build every GOOS/GOARCH we ship51+ run: |52+ for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do53+ echo "building $target"54+ GOOS=${target%/*} GOARCH=${target#*/} CGO_ENABLED=0 go build -trimpath -o /dev/null .55+ done@@ -0,0 +1,37 @@
1+name: Release2+3+on:4+ push:5+ tags:6+ - 'v*.*.*'7+8+permissions:9+ contents: read10+11+jobs:12+ release:13+ name: Test and publish14+ runs-on: ubuntu-24.0415+ timeout-minutes: 2016+ permissions:17+ contents: write18+ steps:19+ - name: Check out exact tag20+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.121+ with:22+ fetch-depth: 023+ - name: Set up Go24+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.025+ with:26+ go-version-file: go.mod27+ cache: true28+ - name: Check29+ run: make check30+ - name: Build and publish with GoReleaser31+ uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.032+ with:33+ distribution: goreleaser34+ version: v2.18.335+ args: release --clean36+ env:37+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}@@ -0,0 +1,35 @@
1+version: 22+3+project_name: lazyopenspec4+5+builds:6+ - env:7+ - CGO_ENABLED=08+ goos: [darwin, linux, windows]9+ goarch: [amd64, arm64]10+ flags: [-trimpath]11+ ldflags:12+ - -s -w -X main.version={{ .Tag }}13+ mod_timestamp: "{{ .CommitTimestamp }}"14+15+archives:16+ - formats: [tar.gz]17+ format_overrides:18+ - goos: windows19+ formats: [zip]20+ name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"21+ files: [README.md, LICENSE]22+23+checksum:24+ name_template: checksums.txt25+26+changelog:27+ use: github28+ sort: asc29+ filters:30+ exclude:31+ - "^Merge "32+33+release:34+ footer: |35+ Install with Go: `go install github.com/FelineStateMachine/lazyopenspec@{{ .Tag }}`@@ -0,0 +1,21 @@
1+MIT License2+3+Copyright (c) 2026 FelineStateMachine4+5+Permission is hereby granted, free of charge, to any person obtaining a copy6+of this software and associated documentation files (the "Software"), to deal7+in the Software without restriction, including without limitation the rights8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell9+copies of the Software, and to permit persons to whom the Software is10+furnished to do so, subject to the following conditions:11+12+The above copyright notice and this permission notice shall be included in all13+copies or substantial portions of the Software.14+15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE21+SOFTWARE.@@ -0,0 +1,28 @@
1+STATICCHECK := honnef.co/go/tools/cmd/staticcheck@v0.8.22+3+4+5+build:6+ go build -trimpath -o bin/lazyopenspec .7+8+test:9+ go test -race ./...10+11+# What CI runs.12+check: fmt-check vet lint test13+14+fmt-check:15+ @out="$$(gofmt -l .)"; if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi16+17+vet:18+ go vet ./...19+20+lint:21+ go run $(STATICCHECK) ./...22+23+# Re-record docs/media/demo.gif (needs vhs, ttyd, ffmpeg). See demo/README.md.24+demo:25+ vhs demo/demo.tape26+27+clean:28+ rm -rf bin dist@@ -5,7 +5,6 @@ import (
55 "path/filepath"66 "reflect"77 "strings"8- "syscall"98 "testing"109 "time"1110 )@@ -408,24 +407,6 @@ func TestFindRoot(t *testing.T) {
408407 })409408 }410409 411-// A FIFO in a change dir must not block Load: reading it would hang forever.412-func TestLoadIgnoresFIFO(t *testing.T) {413- root := copyFixture(t)414- if err := syscall.Mkfifo(filepath.Join(root, "openspec", "changes", "add-due-dates", "pipe.md"), 0o644); err != nil {415- t.Skip("mkfifo unsupported:", err)416- }417- done := make(chan *Snapshot, 1)418- go func() { done <- mustLoad(t, root) }()419- select {420- case s := <-done:421- if len(s.Changes) != 5 {422- t.Errorf("Changes = %v, want the 5 fixture changes", changeIDs(s.Changes))423- }424- case <-time.After(5 * time.Second):425- t.Fatal("Load blocked on a FIFO")426- }427-}428-429410 // One unreadable directory hides only its own contents.430411 func TestLoadSkipsUnreadableDirs(t *testing.T) {431412 if os.Geteuid() == 0 {@@ -0,0 +1,28 @@
1+//go:build unix2+3+package spec4+5+import (6+ "path/filepath"7+ "syscall"8+ "testing"9+ "time"10+)11+12+// A FIFO in a change dir must not block Load: reading it would hang forever.13+func TestLoadIgnoresFIFO(t *testing.T) {14+ root := copyFixture(t)15+ if err := syscall.Mkfifo(filepath.Join(root, "openspec", "changes", "add-due-dates", "pipe.md"), 0o644); err != nil {16+ t.Skip("mkfifo unsupported:", err)17+ }18+ done := make(chan *Snapshot, 1)19+ go func() { done <- mustLoad(t, root) }()20+ select {21+ case s := <-done:22+ if len(s.Changes) != 5 {23+ t.Errorf("Changes = %v, want the 5 fixture changes", changeIDs(s.Changes))24+ }25+ case <-time.After(5 * time.Second):26+ t.Fatal("Load blocked on a FIFO")27+ }28+}@@ -8,6 +8,7 @@ import (
88 99 tea "charm.land/bubbletea/v2"1010 "charm.land/glamour/v2"11+ "charm.land/lipgloss/v2"1112 1213 "github.com/FelineStateMachine/lazyopenspec/internal/gitx"1314 "github.com/FelineStateMachine/lazyopenspec/internal/spec"@@ -352,12 +353,14 @@ func (m Model) tabBar(t target, w int) string {
352353 tabs := m.tabsFor(t)353354 cur, _ := m.currentTab(t)354355 var parts []string355- for _, tb := range tabs {356+ active := 0357+ for i, tb := range tabs {356358 // Style each piece separately: underlined styles re-wrap every rune357359 // and would mangle an already-styled substring.358360 st := th.tabInactive359361 if tb.key == cur.key {360362 st = th.tabActive363+ active = i361364 }362365 part := st.Render(tb.label)363366 if m.now.Sub(tb.mod) < freshWindow {@@ -368,12 +371,42 @@ func (m Model) tabBar(t target, w int) string {
368371 }369372 parts = append(parts, part)370373 }371- left := " " + strings.Join(parts, th.subtle.Render(" │ "))372374 right := ""373375 if m.diffMode {374376 right = th.modSt.Render("[diff vs HEAD]") + " "375377 }376- return spread(left, right, w)378+ return spread(m.tabWindow(parts, active, w-lipgloss.Width(right)-1), right, w)379+}380+381+// tabWindow joins as many tabs as fit in w cells, always including the active382+// one, and marks tabs hidden off either edge with an arrow.383+func (m Model) tabWindow(parts []string, active, w int) string {384+ sep := m.theme.subtle.Render(" │ ")385+ join := func(lo, hi int) string {386+ s := " "387+ if lo > 0 {388+ s += m.theme.muted.Render("‹ ")389+ }390+ s += strings.Join(parts[lo:hi], sep)391+ if hi < len(parts) {392+ s += m.theme.muted.Render(" ›")393+ }394+ return s395+ }396+ if len(parts) == 0 {397+ return " "398+ }399+ lo, hi := active, active+1400+ for grew := true; grew; {401+ grew = false402+ if hi < len(parts) && lipgloss.Width(join(lo, hi+1)) <= w {403+ hi, grew = hi+1, true404+ }405+ if lo > 0 && lipgloss.Width(join(lo-1, hi)) <= w {406+ lo, grew = lo-1, true407+ }408+ }409+ return join(lo, hi)377410 }378411 379412 func (m Model) gitMark(path string) string {@@ -374,15 +374,6 @@ func (m *Model) clampAll() {
374374 }375375 }376376 377-func (m *Model) moveCursor(p panelID, delta int) {378- if p >= numLists {379- return380- }381- m.cursors[p] += delta382- m.clampAll()383- m.lastList = p384-}385-386377 func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {387378 if m.showHelp {388379 m.showHelp = false@@ -180,3 +180,23 @@ func TestSquashedSidePanels(t *testing.T) {
180180 t.Errorf("tall section should not squash: %v", got)181181 }182182 }183+184+func TestTabWindowKeepsActiveTabVisible(t *testing.T) {185+ m := fixtureModel(t, 120, 40)186+ parts := []string{"proposal.md", "design.md", "tasks.md", "Δ one", "Δ two", "Δ three", "Δ four", "Δ five"}187+ for active := range parts {188+ got := ansi.Strip(m.tabWindow(parts, active, 30))189+ if w := ansi.StringWidth(got); w > 30 {190+ t.Errorf("active %d: %q is %d wide", active, got, w)191+ }192+ if !strings.Contains(got, parts[active]) {193+ t.Errorf("active %d: %q hides the active tab", active, got)194+ }195+ }196+ if got := ansi.Strip(m.tabWindow(parts, 7, 30)); !strings.HasPrefix(got, " ‹ ") {197+ t.Errorf("tabs hidden on the left should be marked: %q", got)198+ }199+ if got := ansi.Strip(m.tabWindow(parts, 0, 30)); !strings.HasSuffix(got, " ›") {200+ t.Errorf("tabs hidden on the right should be marked: %q", got)201+ }202+}@@ -6,6 +6,7 @@ import (
66 "flag"77 "fmt"88 "os"9+ "runtime/debug"910 "time"1011 1112 tea "charm.land/bubbletea/v2"@@ -14,7 +15,20 @@ import (
1415 "github.com/FelineStateMachine/lazyopenspec/internal/ui"1516 )1617 17-var version = "dev"18+// version is set by release builds (-ldflags "-X main.version=v1.2.3").19+var version = ""20+21+// versionString reports the release version, or the module version that22+// `go install ...@vX` records, or "dev".23+func versionString() string {24+ if version != "" {25+ return version26+ }27+ if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {28+ return info.Main.Version29+ }30+ return "dev"31+}1832 1933 func main() {2034 interval := flag.Duration("interval", 400*time.Millisecond, "how often to poll openspec/ for edits")@@ -37,7 +51,7 @@ func main() {
3751 positional, args = append(positional, args[0]), args[1:]3852 }3953 if *showVersion {40- fmt.Println("lazyopenspec", version)54+ fmt.Println("lazyopenspec", versionString())4155 return4256 }4357