From d90a8367128ed38ecd1520e30e065215033127ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Sun, 25 Oct 2026 22:44:57 +0200 Subject: [PATCH 1/5] style: gofmt housekeeping across packages --- internal/analyzer/chipset.go | 4 +-- internal/emitter/parity_test.go | 14 +++++------ internal/hunk/parser_test.go | 14 +++++------ internal/m68k/cycles_test.go | 1 - internal/modules/modules.go | 5 ++-- internal/project/project.go | 22 ++++++++--------- internal/tui/actions.go | 1 - internal/tui/app.go | 44 ++++++++++++++++----------------- internal/tui/bitplane.go | 10 ++++---- internal/tui/tui_test.go | 1 - internal/tui/types.go | 20 +++++++-------- 11 files changed, 67 insertions(+), 69 deletions(-) diff --git a/internal/analyzer/chipset.go b/internal/analyzer/chipset.go index 161a1a1..5b0c504 100644 --- a/internal/analyzer/chipset.go +++ b/internal/analyzer/chipset.go @@ -109,10 +109,10 @@ const ( ) const ( - copperMaxSteps = 1024 + copperMaxSteps = 1024 HPosPerLine uint32 = 227 VPosPerFrame uint32 = 313 - CyclesPerLine uint32 = HPosPerLine * 2 // 454 CPU cycles + CyclesPerLine uint32 = HPosPerLine * 2 // 454 CPU cycles CyclesPerFrame uint32 = VPosPerFrame * CyclesPerLine // 142,102 CPU cycles dsksyncPeriodCycles uint32 = 12800 diff --git a/internal/emitter/parity_test.go b/internal/emitter/parity_test.go index ef24704..9d51d4c 100644 --- a/internal/emitter/parity_test.go +++ b/internal/emitter/parity_test.go @@ -25,13 +25,13 @@ func buildSyntheticHunkExecutable() []byte { // HUNK_HEADER: 3 hunks (CODE public 28B, DATA chip 8B on disk / 16B alloc, BSS chip 32B) push(hunk.HunkHeader) - push(0) // resident libs - push(3) // table_size = 3 - push(0) // first_hunk = 0 - push(2) // last_hunk = 2 - push(7) // hunk0: 7 longs (28B) CODE public - push((1 << 30) | 4) // hunk1: 4 longs (16B alloc) DATA chip - push((1 << 30) | 8) // hunk2: 8 longs (32B alloc) BSS chip + push(0) // resident libs + push(3) // table_size = 3 + push(0) // first_hunk = 0 + push(2) // last_hunk = 2 + push(7) // hunk0: 7 longs (28B) CODE public + push((1 << 30) | 4) // hunk1: 4 longs (16B alloc) DATA chip + push((1 << 30) | 8) // hunk2: 8 longs (32B alloc) BSS chip // Hunk 0: CODE (28 bytes = 7 longs) // +0x00: 48 E7 40 20 movem.l d1/a2,-(a7) diff --git a/internal/hunk/parser_test.go b/internal/hunk/parser_test.go index 36d6b9f..707a5e8 100644 --- a/internal/hunk/parser_test.go +++ b/internal/hunk/parser_test.go @@ -16,13 +16,13 @@ func TestParseSyntheticMultiHunkBinary(t *testing.T) { // HUNK_HEADER: 3 hunks (CODE public 8B, DATA chip 8B, BSS chip 64B) push(HunkHeader) - push(0) // resident libs - push(3) // table_size = 3 - push(0) // first_hunk = 0 - push(2) // last_hunk = 2 - push(2) // hunk0: 2 longs (8B) public - push((1 << 30) | 2) // hunk1: 2 longs (8B) chip - push((1 << 30) | 16) // hunk2: 16 longs (64B) chip + push(0) // resident libs + push(3) // table_size = 3 + push(0) // first_hunk = 0 + push(2) // last_hunk = 2 + push(2) // hunk0: 2 longs (8B) public + push((1 << 30) | 2) // hunk1: 2 longs (8B) chip + push((1 << 30) | 16) // hunk2: 16 longs (64B) chip // Hunk 0: CODE + RELOC32 to hunk 1 push(HunkCode) diff --git a/internal/m68k/cycles_test.go b/internal/m68k/cycles_test.go index e2178f3..56d4200 100644 --- a/internal/m68k/cycles_test.go +++ b/internal/m68k/cycles_test.go @@ -487,4 +487,3 @@ func TestExhaustiveAll65536OpcodesHaveNonZeroTiming(t *testing.T) { t.Fatalf("expected >40000 valid M68000 opcodes, got %d", validCount) } } - diff --git a/internal/modules/modules.go b/internal/modules/modules.go index 15b6dca..af10040 100644 --- a/internal/modules/modules.go +++ b/internal/modules/modules.go @@ -72,8 +72,9 @@ type Recognizer interface { } // Recognizers returns the built-in recognizer registry in ExoticRipper format-ID order: -// 2 Final Music System, 5/6 Future Composer, 22 SoundTracker 2.6, 23 ProTracker, -// 35 David Whittaker, 42 Old SoundTracker, 43 Delta Music 1.0. +// +// 2 Final Music System, 5/6 Future Composer, 22 SoundTracker 2.6, 23 ProTracker, +// 35 David Whittaker, 42 Old SoundTracker, 43 Delta Music 1.0. func Recognizers() []Recognizer { return []Recognizer{ FinalMusicSystem{}, diff --git a/internal/project/project.go b/internal/project/project.go index 090369b..95da177 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -345,17 +345,17 @@ type AnalysisData struct { } type ProjectData struct { - TargetName string `json:"target_name"` - Segments []Segment `json:"segments"` - Spaces []Space `json:"spaces"` - ActiveSpace string `json:"active_space"` - Output string `json:"output"` - EntryPoints []EntryPoint `json:"entry_points"` - MemoryRegions []MemoryRegion `json:"memory_regions"` - Structs map[string]map[string]string `json:"structs"` - RegisterAliases map[string]string `json:"register_aliases"` - Carves []Carve `json:"carves"` - Analysis AnalysisData `json:"_analysis"` + TargetName string `json:"target_name"` + Segments []Segment `json:"segments"` + Spaces []Space `json:"spaces"` + ActiveSpace string `json:"active_space"` + Output string `json:"output"` + EntryPoints []EntryPoint `json:"entry_points"` + MemoryRegions []MemoryRegion `json:"memory_regions"` + Structs map[string]map[string]string `json:"structs"` + RegisterAliases map[string]string `json:"register_aliases"` + Carves []Carve `json:"carves"` + Analysis AnalysisData `json:"_analysis"` } type Project struct { diff --git a/internal/tui/actions.go b/internal/tui/actions.go index 2f2dd3a..abf53d9 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -479,4 +479,3 @@ func (a *App) CarveBitplaneRange() { a.ReloadAtCursor() a.StatusMsg = fmt.Sprintf("Carved bitplane range +$%05X..+$%05X (%s)", startOff, endOff, label) } - diff --git a/internal/tui/app.go b/internal/tui/app.go index 059dc7a..036f0d1 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -13,28 +13,28 @@ import ( // App holds the full interactive TUI state and implements tea.Model. type App struct { - Proj *project.Project - DB *db.Controller - Theme Theme - SelectedIdx int - ScrollOffset int - DisasmRows []ListingRow - ShouldQuit bool - Modal Modal - NavHistory []uint32 - StatusMsg string - BinaryData []byte - BaseAddr uint32 - IsRelocatable bool - CachedCodeBytes uint32 - DataByteOff int - UndoStack []UndoEntry - SelectionStart *uint32 - Width int - Height int - LastWidth int - PixelWidth int - PixelHeight int + Proj *project.Project + DB *db.Controller + Theme Theme + SelectedIdx int + ScrollOffset int + DisasmRows []ListingRow + ShouldQuit bool + Modal Modal + NavHistory []uint32 + StatusMsg string + BinaryData []byte + BaseAddr uint32 + IsRelocatable bool + CachedCodeBytes uint32 + DataByteOff int + UndoStack []UndoEntry + SelectionStart *uint32 + Width int + Height int + LastWidth int + PixelWidth int + PixelHeight int CellWidth int CellHeight int PendingEscapeSeq string diff --git a/internal/tui/bitplane.go b/internal/tui/bitplane.go index 1efa538..8e52d50 100644 --- a/internal/tui/bitplane.go +++ b/internal/tui/bitplane.go @@ -733,12 +733,12 @@ func EncodeIFFILBM(st *BitplaneState, data []byte) ([]byte, error) { binary.BigEndian.PutUint16(bmhd[4:6], 0) // x binary.BigEndian.PutUint16(bmhd[6:8], 0) // y bmhd[8] = uint8(planes) - bmhd[9] = 0 // masking: mskNone - bmhd[10] = 0 // compression: cmpNone - bmhd[11] = 0 // pad + bmhd[9] = 0 // masking: mskNone + bmhd[10] = 0 // compression: cmpNone + bmhd[11] = 0 // pad binary.BigEndian.PutUint16(bmhd[12:14], 0) // transparentColor - bmhd[14] = 10 // xAspect - bmhd[15] = 11 // yAspect (PAL standard) + bmhd[14] = 10 // xAspect + bmhd[15] = 11 // yAspect (PAL standard) binary.BigEndian.PutUint16(bmhd[16:18], uint16(width)) binary.BigEndian.PutUint16(bmhd[18:20], uint16(height)) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7ddf77d..38b0efc 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1009,4 +1009,3 @@ func TestBitplaneViewerInteractive(t *testing.T) { t.Fatalf("expected Undone after 'ctrl+z', got %q", app.StatusMsg) } } - diff --git a/internal/tui/types.go b/internal/tui/types.go index 32ba17a..2798c61 100644 --- a/internal/tui/types.go +++ b/internal/tui/types.go @@ -259,16 +259,16 @@ type StructEditorState struct { // Modal holds the state of the currently active modal dialog. type Modal struct { - Kind ModalKind - Input TextInput - SearchHits []SearchHit - Xrefs []XrefItem - Filter string - AllFuncs []FuncItem - ListSelect int - StructState StructEditorState - CarveStart uint32 - CarveEnd uint32 + Kind ModalKind + Input TextInput + SearchHits []SearchHit + Xrefs []XrefItem + Filter string + AllFuncs []FuncItem + ListSelect int + StructState StructEditorState + CarveStart uint32 + CarveEnd uint32 FileBrowser FileBrowserState SegmentForm SegmentFormState Bitplane *BitplaneState From 5c948ce552ae86969def6356d029a4d27b19e972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Sun, 25 Oct 2026 22:44:57 +0200 Subject: [PATCH 2/5] chore(ci): add GitHub Actions CI and tagged release workflows - Add .github/workflows/ci.yml to verify gofmt, go mod verify/tidy, go vet, SQL isolation invariant, pure-Go (CGO_ENABLED=0) builds/tests across Linux/macOS/Windows, and Linux race detector on all branch pushes and PRs - Add .github/workflows/release.yml to build and publish cross-platform release archives (linux/darwin/windows amd64/arm64 + SHA-256 checksums) when master is tagged with vX.Y.Z - Wire Version on rootCmd in cmd/hunks/main.go for -ldflags '-X main.version=...' --- .github/workflows/ci.yml | 92 ++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 99 +++++++++++++++++++++++++++++++++++ cmd/hunks/main.go | 3 ++ 3 files changed, 194 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8e2d46c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +# Continuous Integration workflow for hunks. +# Note: Byte-for-byte vasmm68k_mot reassembly parity on real Amiga targets is a +# local development verification step (see TESTING.md); CI runs strictly on +# synthetic, copyright-free unit tests (AGENTS.md Invariant #6). +name: CI + +on: + push: + branches: ['**'] + tags-ignore: ['v*'] + pull_request: + branches: [master] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CGO_ENABLED: "0" + +jobs: + lint: + name: Lint & Invariants + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Check gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "The following files are not formatted with gofmt:" + echo "$unformatted" + gofmt -d . + exit 1 + fi + + - name: Verify Go modules + run: | + go mod verify + go mod tidy + git diff --exit-code go.mod go.sum + + - name: Run go vet + run: go vet ./... + + - name: Verify SQL isolation invariant (AGENTS.md Invariant #2) + run: | + if grep -rnE '\.(Query|QueryRow|Exec)\(' cmd/ internal/analyzer/ internal/emitter/ internal/callgraph/ internal/project/ internal/tui/ --exclude='*_test.go'; then + echo "Error: raw SQL found outside internal/db/ (violates AGENTS.md Hard Invariant #2)" + exit 1 + fi + + test: + name: Build & Test (${{ matrix.os }}) + needs: lint + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Build CLI (pure Go, CGO_ENABLED=0) + run: go build -v -trimpath ./cmd/hunks + + - name: Run unit tests (CGO_ENABLED=0) + run: go test -v ./... + + - name: Run race detector (Linux) + if: runner.os == 'Linux' + env: + CGO_ENABLED: "1" + run: go test -race ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a66e394 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Release + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]*' + +permissions: + contents: write + +env: + CGO_ENABLED: "0" + +jobs: + release: + name: Build & Publish Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify tag is on master + run: | + git fetch origin master + if ! git merge-base --is-ancestor HEAD origin/master; then + echo "Error: tag ${GITHUB_REF_NAME} ($(git rev-parse --short HEAD)) is not an ancestor of origin/master" + exit 1 + fi + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Pre-release verification gate (gofmt, go vet, go test) + run: | + test -z "$(gofmt -l .)" + go vet ./... + go test -v ./... + + - name: Cross-compile and package release archives + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME}" + mkdir -p dist + + targets=( + "linux:amd64" + "linux:arm64" + "darwin:amd64" + "darwin:arm64" + "windows:amd64" + "windows:arm64" + ) + + for target in "${targets[@]}"; do + GOOS="${target%%:*}" + GOARCH="${target##*:}" + STAGE="stage/hunks_${VERSION}_${GOOS}_${GOARCH}" + mkdir -p "${STAGE}" + + BIN="hunks" + if [ "${GOOS}" = "windows" ]; then + BIN="hunks.exe" + fi + + echo "Building ${GOOS}/${GOARCH} -> ${STAGE}/${BIN}" + GOOS="${GOOS}" GOARCH="${GOARCH}" CGO_ENABLED=0 \ + go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \ + -o "${STAGE}/${BIN}" ./cmd/hunks + + cp README.md MANUAL.md "${STAGE}/" + + if [ "${GOOS}" = "windows" ]; then + (cd "${STAGE}" && zip -q "../../dist/hunks_${VERSION}_${GOOS}_${GOARCH}.zip" "${BIN}" README.md MANUAL.md) + else + tar -C "${STAGE}" -czf "dist/hunks_${VERSION}_${GOOS}_${GOARCH}.tar.gz" "${BIN}" README.md MANUAL.md + fi + done + + (cd dist && sha256sum hunks_* > checksums.txt) + ls -lh dist/ + cat dist/checksums.txt + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release upload "${GITHUB_REF_NAME}" dist/* --clobber + else + gh release create "${GITHUB_REF_NAME}" dist/* \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes \ + --verify-tag + fi diff --git a/cmd/hunks/main.go b/cmd/hunks/main.go index 79cf212..fdce0bc 100644 --- a/cmd/hunks/main.go +++ b/cmd/hunks/main.go @@ -22,6 +22,8 @@ import ( "github.com/spf13/cobra" ) +var version = "dev" + func parseHex(val string) (uint32, error) { return project.ParseUint32(val) } @@ -271,6 +273,7 @@ func main() { rootCmd := &cobra.Command{ Use: "hunks", Short: "hunks — Amiga M68k Reverse Engineering System", + Version: version, SilenceUsage: true, } rootCmd.PersistentFlags().StringVarP(&workspacePath, "path", "p", ".", "Workspace root directory (defaults to current working directory)") From 692ce211f60d7b1e9fe8c3f23cde3aa5bf9893f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Sun, 25 Oct 2026 22:46:25 +0200 Subject: [PATCH 3/5] fix(git): unignore and track go.mod (!go.mod after *.mod) --- .gitignore | 1 + go.mod | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 go.mod diff --git a/.gitignore b/.gitignore index 27ba678..d8ae132 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ out.s *.rom *.kick *.mod +!go.mod *.xm *.lha *.lzx diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3bb3254 --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module github.com/mikolas/hunks + +go 1.27 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/google/btree v1.1.3 + github.com/jenska/m68kemu v1.8.1 + github.com/mattn/go-runewidth v0.0.16 + github.com/spf13/cobra v1.10.2 + modernc.org/sqlite v1.61.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.1.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jenska/m68kdasm v1.4.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.1.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.3.8 // indirect + modernc.org/libc v1.79.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) From cce83a8f03d79c599ab87f3efa5626716446f1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Sun, 25 Oct 2026 22:47:56 +0200 Subject: [PATCH 4/5] chore(mod): tidy direct dependencies in go.mod --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 3bb3254..25977b4 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.27 require ( github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/x/term v0.2.1 github.com/google/btree v1.1.3 github.com/jenska/m68kemu v1.8.1 github.com/mattn/go-runewidth v0.0.16 + github.com/muesli/cancelreader v0.2.2 github.com/spf13/cobra v1.10.2 modernc.org/sqlite v1.61.0 ) @@ -17,7 +19,6 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect @@ -27,7 +28,6 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect From b43a8e2f48c178f56210411ba94059fd443b4ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Sun, 25 Oct 2026 22:53:27 +0200 Subject: [PATCH 5/5] fix(tui): honor XDG_CONFIG_HOME and ~/.config/hunks/theme.json across OSes --- internal/tui/theme.go | 6 ++++++ internal/tui/tui_test.go | 1 + 2 files changed, 7 insertions(+) diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 861984f..741fc69 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -107,9 +107,15 @@ func LoadTheme() Theme { if envPath := strings.TrimSpace(os.Getenv("HUNKS_THEME")); envPath != "" { candidates = append(candidates, envPath) } + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + candidates = append(candidates, filepath.Join(xdg, "hunks", "theme.json")) + } if cfgDir, err := os.UserConfigDir(); err == nil && cfgDir != "" { candidates = append(candidates, filepath.Join(cfgDir, "hunks", "theme.json")) } + if home, err := os.UserHomeDir(); err == nil && home != "" { + candidates = append(candidates, filepath.Join(home, ".config", "hunks", "theme.json")) + } for _, path := range candidates { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 38b0efc..7393acc 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -754,6 +754,7 @@ func TestThemeLoadAndTokenization(t *testing.T) { // Partial ~/.config/hunks/theme.json override preserves untouched defaults. tmp := t.TempDir() + t.Setenv("HUNKS_THEME", "") t.Setenv("XDG_CONFIG_HOME", tmp) cfgDir := filepath.Join(tmp, "hunks") if err := os.MkdirAll(cfgDir, 0o755); err != nil {