Skip to content
Merged
52 changes: 52 additions & 0 deletions .github/scripts/win-native-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/bin/bash
# Runs the Windows native window once against a real backend, the check
# mac-native-smoke.sh is for macOS: a windowless lich, one shell session, and
# lich-native on top of it. The window has to come up, connect and draw (-shot,
# the frame it renders), still be alive afterwards and close when lich quits;
# the shot and the logs land in $RUNNER_TEMP/diag for the workflow to upload.
# Runs in git-bash.
#
# usage: win-native-smoke.sh <lich.exe> <lich-native.exe>
set -eu

lich="$1"
native="$2"
export LICH_LISTEN_PORT=47898
config="$(cygpath -u "$APPDATA")/lich"
runtime="$config/runtime.json"
diag="$RUNNER_TEMP/diag"
proj="$RUNNER_TEMP/proj"
mkdir -p "$diag" "$proj"

fail() {
echo "::error::$1"
cat "$diag/native.out" 2>/dev/null || true
cp "$config/lich.log" "$config/lich-native.log" "$diag/" 2>/dev/null || true
cat "$config/lich.log" "$config/lich-native.log" 2>/dev/null || true
exit 1
}

"$lich" --no-window > "$diag/backend.out" 2>&1 &
for _ in $(seq 1 30); do
sleep 1
test -s "$runtime" && break
done
test -s "$runtime" || fail "lich never wrote runtime.json"
"$lich" open --project "$(cygpath -w "$proj")" --kind shell || fail "lich open exited $?"

"$native" -runtime "$(cygpath -w "$runtime")" -shot "$(cygpath -w "$diag/shot.png")" -shot-after 8s > "$diag/native.out" 2>&1 &
pid=$!
for _ in $(seq 1 40); do
sleep 1
test -s "$diag/shot.png" && break
kill -0 "$pid" 2>/dev/null || fail "lich-native exited before it drew"
done
test -s "$diag/shot.png" || fail "lich-native never wrote its shot"
sleep 2
kill -0 "$pid" 2>/dev/null || fail "lich-native died after its shot"
cp "$config/lich-native.log" "$diag/" 2>/dev/null || true

"$lich" quit || fail "lich quit exited $?"
for _ in $(seq 1 10); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done
kill -0 "$pid" 2>/dev/null && fail "lich-native outlived lich quit"
echo "lich-native came up, connected, drew and closed with lich; see the uploaded shot"
91 changes: 88 additions & 3 deletions .github/workflows/native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ jobs:
fi

# The Windows window is cross-built from Linux with Zig as cgo's toolchain
# (native/build-windows.sh); this proves it still links. Running it is left
# to a real Windows machine.
# (native/build-windows.sh), and so are its tests and the backend; the
# windows job below runs them on a real Windows machine.
windows-build:
runs-on: ubuntu-latest
defaults:
Expand Down Expand Up @@ -132,7 +132,92 @@ jobs:
key: libghostty-vt-windows-${{ steps.ghostty.outputs.commit }}

- name: Build lich-native.exe
run: LIBGHOSTTY_VT_WINDOWS="$HOME/libghostty-vt-windows" ./build-windows.sh "$RUNNER_TEMP/lich-native.exe"
run: LIBGHOSTTY_VT_WINDOWS="$HOME/libghostty-vt-windows" ./build-windows.sh "$RUNNER_TEMP/out/lich-native.exe"

# One test binary per package, at the package's own path, so the windows
# job runs each from its package directory the way go test does. The
# toolchain is build-windows.sh's.
- name: Build the tests for Windows
env:
GOOS: windows
GOARCH: amd64
CGO_ENABLED: "1"
CC: zig cc -target x86_64-windows-gnu
CXX: zig c++ -target x86_64-windows-gnu
CGO_LDFLAGS_ALLOW: '.*\.lib'
run: |
export PKG_CONFIG_PATH="$HOME/libghostty-vt-windows/share/pkgconfig"
for dir in $(go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.Dir}}{{end}}' ./...); do
rel="${dir#"$PWD"/}"
go test -c -o "$RUNNER_TEMP/out/tests/$rel/pkg.test.exe" "./$rel"
done

# The native window reads nothing from the embedded frontend, so an empty
# dist is enough for go:embed.
- name: Build lich.exe
working-directory: .
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o "$RUNNER_TEMP/out/lich.exe" .

- uses: actions/upload-artifact@v7
with:
name: native-windows-build
path: ${{ runner.temp }}/out
retention-days: 1

# Runs on Windows what windows-build made there: the native tests, then the
# window itself once against a real backend, which has to come up and draw.
windows:
needs: windows-build
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7

- uses: actions/download-artifact@v8
with:
name: native-windows-build
path: ${{ runner.temp }}/out

# Like the Linux job, a render test that skips for want of a GPU fails
# the job: Windows always has one, WARP's software one at least.
- name: go test
working-directory: native
run: |
out="$(cygpath -u "$RUNNER_TEMP")/out"
status=0
for exe in $(cd "$out/tests" && find . -name pkg.test.exe); do
rel="$(dirname "$exe")"
echo "### $rel" >> test.log
(cd "$rel" && "$out/tests/$exe" -test.v) >> test.log 2>&1 || status=1
done
[ "$status" = 0 ] || { cat test.log; exit 1; }
grep -E '^(### |--- FAIL|--- SKIP|PASS|FAIL)' test.log
if grep -q 'no headless GPU' test.log; then
echo "::error::render tests skipped: no headless GPU"
grep -B1 'no headless GPU' test.log
exit 1
fi

# Runs after a failed go test too: whether the window comes up at all is
# its own answer.
- name: Run the window against the backend
if: success() || failure()
run: |
out="$(cygpath -u "$RUNNER_TEMP")/out"
bash .github/scripts/win-native-smoke.sh "$out/lich.exe" "$out/lich-native.exe"

- name: Upload what the window drew
if: always()
uses: actions/upload-artifact@v7
with:
name: lich-native-windows
path: ${{ runner.temp }}/diag
if-no-files-found: ignore

# Gio's Cocoa and Metal need Apple's SDK, so the macOS window builds on a Mac
# and nowhere else. Apple Silicon only on a PR: release.yml builds Intel too.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- The native window opens again on Windows and macOS, where it closed right
away looking for fontconfig to draw the code and diff views.
- Emoji in the native window's terminal are drawn in color on Windows, where
they came out as plain outlines.
- The update command offered to a macOS install now taps `lichdotdev/tap` before upgrading. An install from the old `omartelo/tap` failed with "Cask 'lichdotdev/tap/lich' is unavailable", since Homebrew does not follow a moved tap on its own; tap it once with `brew tap lichdotdev/tap` to upgrade from an older lich.
- In the native window, "Unfold long pastes" now unfolds Claude Code and Kiro CLI pastes: the clipboard, a batch of review comments, the prompt a pull request or issue opens a session with, and dropped or attached file paths.

Expand Down
55 changes: 49 additions & 6 deletions internal/project/waitdelay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,39 @@ import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)

// helperEnv selects which half of the process tree a helper run plays.
const helperEnv = "LICH_WAITDELAY_HELPER"

// releaseEnv names the directory the grandchild watches: the test writes
// releaseFile there once it is done with the grandchild, which then exits and
// writes exitedFile. Left to sleep out its hold, the grandchild outlived a
// package that finished sooner, still running the test binary go test was
// deleting: "unlinkat ... project.test.exe: Access is denied" on Windows.
const (
releaseEnv = "LICH_WAITDELAY_RELEASE"
releaseFile = "release"
exitedFile = "exited"
)

// grandchildHold is how long the grandchild holds the pipe at most, far past
// any budget the parent could set; pollEvery is how often it and the test look
// for each other's file.
const (
grandchildHold = 10 * time.Second
pollEvery = 20 * time.Millisecond
)

// TestSubprocessHelper is not a test of anything: it is the child and the
// grandchild TestSubprocessBudgetOutlivesAGrandchild spawns, re-executing this
// binary because a shell one-liner would not run on Windows. As "child" it
// starts a grandchild on its own stdout — the pipe the parent is reading — and
// exits at once; as "grandchild" it holds that pipe open far past any budget
// the parent could set. A plain run does nothing.
// exits at once; as "grandchild" it holds that pipe open until the test
// releases it. A plain run does nothing.
func TestSubprocessHelper(t *testing.T) {
t.Parallel()
switch os.Getenv(helperEnv) {
Expand All @@ -29,21 +49,44 @@ func TestSubprocessHelper(t *testing.T) {
}
os.Exit(0)
case "grandchild":
time.Sleep(10 * time.Second)
dir := os.Getenv(releaseEnv)
awaitFile(filepath.Join(dir, releaseFile), grandchildHold)
if err := os.WriteFile(filepath.Join(dir, exitedFile), nil, 0o644); err != nil {
os.Exit(1)
}
os.Exit(0)
}
}

// awaitFile reports whether path appeared within limit.
func awaitFile(path string, limit time.Duration) bool {
for deadline := time.Now().Add(limit); time.Now().Before(deadline); time.Sleep(pollEvery) {
if _, err := os.Stat(path); err == nil {
return true
}
}
return false
}

// A budget is only real if it bounds the call rather than the child. The child
// here exits immediately and the grandchild behind it keeps stdout open for ten
// seconds: with nothing closing that pipe, Output waits all ten and reports
// here exits immediately and the grandchild behind it keeps stdout open until
// released: with nothing closing that pipe, Output waits for it and reports
// success, deadline or none.
func TestSubprocessBudgetOutlivesAGrandchild(t *testing.T) {
t.Parallel()
dir := t.TempDir()
t.Cleanup(func() {
if err := os.WriteFile(filepath.Join(dir, releaseFile), nil, 0o644); err != nil {
t.Fatalf("release the grandchild: %v", err)
}
if !awaitFile(filepath.Join(dir, exitedFile), grandchildHold) {
t.Fatal("the grandchild did not exit once released")
}
})
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
cmd := commandContext(ctx, os.Args[0], "-test.run=TestSubprocessHelper")
cmd.Env = append(cmd.Env, helperEnv+"=child")
cmd.Env = append(cmd.Env, helperEnv+"=child", releaseEnv+"="+dir)

start := time.Now()
_, err := cmd.Output()
Expand Down
6 changes: 6 additions & 0 deletions internal/project/worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ func commitTemplate(dir string, run func(args ...string) error) error {
if err := run("config", "core.autocrlf", "false"); err != nil {
return err
}
// A commit starts `git maintenance run --auto --detach` (git 2.46+), which
// outlives it holding objects/maintenance.lock: initRepo's copy of the
// template then lists the lock and finds it gone when it opens it.
if err := run("config", "maintenance.auto", "false"); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o644); err != nil {
return err
}
Expand Down
19 changes: 19 additions & 0 deletions native/cmd/lich-native/code_view_fontconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//go:build !windows && !darwin

package main

import (
"gioui.org/unit"

"github.com/lichdotdev/godemirror"
)

// newCodeView builds the code view in family by its fontconfig name, which
// keeps godemirror's own fallback for characters the family lacks.
func newCodeView(family string, size unit.Sp) (*godemirror.View, error) {
return godemirror.New(family, size)
}

func newDiffView(family string, size unit.Sp, opts godemirror.DiffOptions) (*godemirror.DiffView, error) {
return godemirror.NewDiffView(family, size, opts)
}
68 changes: 68 additions & 0 deletions native/cmd/lich-native/code_view_fontscan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//go:build windows || darwin

package main

import (
"os"

"gioui.org/font"
"gioui.org/unit"

"github.com/lichdotdev/godemirror"
"github.com/lichdotdev/lich/native/fonts"
)

// newCodeView builds the code view from the files fonts.Locate picks for
// family: godemirror resolves a family name only through fontconfig's fc-match,
// which Windows and macOS do not ship. A character the family lacks is drawn
// as a box.
func newCodeView(family string, size unit.Sp) (*godemirror.View, error) {
files, err := codeFonts(family)
if err != nil {
return nil, err
}
return godemirror.NewFromFonts(files, size)
}

func newDiffView(family string, size unit.Sp, opts godemirror.DiffOptions) (*godemirror.DiffView, error) {
files, err := codeFonts(family)
if err != nil {
return nil, err
}
return godemirror.NewDiffViewFromFonts(files, size, opts)
}

// codeFonts reads family's four styles. godemirror draws a collection's first
// face, so a style fonts.Locate finds deeper in a collection is drawn with
// that one, as the terminal's rasterizer does.
func codeFonts(family string) (godemirror.Fonts, error) {
read := map[string][]byte{}
load := func(weight font.Weight, style font.Style) ([]byte, error) {
loc, err := fonts.Locate(font.Font{Typeface: font.Typeface(family), Weight: weight, Style: style})
if err != nil {
return nil, err
}
if data, ok := read[loc.Path]; ok {
return data, nil
}
data, err := os.ReadFile(loc.Path)
if err != nil {
return nil, err
}
read[loc.Path] = data
return data, nil
}
var files godemirror.Fonts
var err error
if files.Regular, err = load(font.Normal, font.Regular); err != nil {
return files, err
}
if files.Bold, err = load(font.Bold, font.Regular); err != nil {
return files, err
}
if files.Italic, err = load(font.Normal, font.Italic); err != nil {
return files, err
}
files.BoldItalic, err = load(font.Bold, font.Italic)
return files, err
}
17 changes: 17 additions & 0 deletions native/cmd/lich-native/code_view_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package main

import (
"testing"

"github.com/lichdotdev/godemirror"
"github.com/lichdotdev/lich/native/fonts"
)

func TestTheCodeAndDiffViewsOpenInTheSystemMonospace(t *testing.T) {
if _, err := newCodeView(fonts.SystemMono, 13); err != nil {
t.Fatalf("code view: %v", err)
}
if _, err := newDiffView(fonts.SystemMono, 13, godemirror.DiffOptions{}); err != nil {
t.Fatalf("diff view: %v", err)
}
}
2 changes: 1 addition & 1 deletion native/cmd/lich-native/dock_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ type codeTab struct {
}

func newCodeTab(client *lichclient.Client, invalidate func(), family string, size unit.Sp, comments *commentStore, notify func(ui.Toast)) (*codeTab, error) {
view, err := godemirror.New(family, size)
view, err := newCodeView(family, size)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion native/cmd/lich-native/dock_code_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ func (p *preview) setFont(family string, size unit.Sp) error {
if family == p.family && size == p.size {
return nil
}
view, err := godemirror.New(family, size)
view, err := newCodeView(family, size)
if err != nil {
return err
}
Expand Down
Loading
Loading