diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8e64d0..3887b2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Every update opens the native window.** The first launch of each release + switches lich to the native window, falling back to the web one where it + cannot start; switch back in Settings and lich stays on the web window until + the next release. The first time the native window opens, it says it is + experimental and links to a bug report. - **A lighter status ring in the native window.** The ring around a session card's icon is drawn thinner, to read at the weight the web window gives it. diff --git a/internal/system/native.go b/internal/system/native.go index 4ade700d..af6c5c92 100644 --- a/internal/system/native.go +++ b/internal/system/native.go @@ -14,8 +14,22 @@ const ( WindowWeb = "web" WindowNative = "native" WindowKindKey = "window.kind" + // NativeReleaseKey is the release that last put lich on the native window. + NativeReleaseKey = "window.nativeRelease" ) +// devVersion is main.version in a build without a release tag. +const devVersion = "dev" + +// SwitchesToNative reports whether a launch of version puts lich on the +// native window, whatever was chosen before: once per release, so every +// update is tried on it while it is experimental, and a user who switched +// back keeps the web window until the next one. A dev build never does, nor +// an install without the native binary, which would only fall back. +func SwitchesToNative(version, lastSwitched string, nativeShipped bool) bool { + return nativeShipped && version != devVersion && version != lastSwitched +} + // NativeHooks are how lich drives the native window (`lich native`), wired by // main. type NativeHooks struct { diff --git a/internal/system/system_test.go b/internal/system/system_test.go index e5522553..217967b8 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -600,3 +600,21 @@ func TestNativeWindowThatCannotStartFallsBackToTheWebOne(t *testing.T) { t.Fatalf("launch failing: err %v, web shown %d; want the web window", err, r.shown) } } + +func TestSwitchesToNativeOncePerRelease(t *testing.T) { + cases := []struct { + name, version, last string + shipped, want bool + }{ + {"first launch of a release", "0.69.0", "", true, true}, + {"an update", "0.69.0", "0.68.0", true, true}, + {"the same release again", "0.69.0", "0.69.0", true, false}, + {"no native binary", "0.69.0", "0.68.0", false, false}, + {"a dev build", "dev", "", true, false}, + } + for _, c := range cases { + if got := SwitchesToNative(c.version, c.last, c.shipped); got != c.want { + t.Errorf("%s: SwitchesToNative(%q, %q, %v) = %v, want %v", c.name, c.version, c.last, c.shipped, got, c.want) + } + } +} diff --git a/main.go b/main.go index c4e700c1..3e1ddb5b 100644 --- a/main.go +++ b/main.go @@ -367,6 +367,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub Close: func() { hub.Emit(windowCloseEvent, nil) }, Locate: func() (string, error) { return cli.NativeExecutable(os.Getenv) }, }) + switchToNativeOnRelease(db) sys.SetWindowChoice(func() string { return windowChoice(db) }) dispatcher.Register("system", sys) // Deleting a session for good takes any prompt parked on it, and the row is @@ -951,6 +952,28 @@ func windowChoice(db *store.Service) string { return kind } +// switchToNativeOnRelease chooses the native window on the first launch of a +// release (system.SwitchesToNative). Failing to write leaves the choice the +// user had. +func switchToNativeOnRelease(db *store.Service) { + last, err := db.GetSetting(system.NativeReleaseKey, "") + if err != nil { + slog.Warn("read the release that last chose the native window", "err", err) + return + } + _, locateErr := cli.NativeExecutable(os.Getenv) + if !system.SwitchesToNative(version, last, locateErr == nil) { + return + } + if err := db.SetSetting(system.WindowKindKey, "", system.WindowNative); err != nil { + slog.Warn("choose the native window for this release", "err", err) + return + } + if err := db.SetSetting(system.NativeReleaseKey, "", version); err != nil { + slog.Warn("record the release that chose the native window", "err", err) + } +} + // showWindow opens the chosen window where nobody waits on the answer but // the log: the launch and the tray. func showWindow(sys *system.Service, from string) { diff --git a/native/cmd/lich-native/experimental_notice.go b/native/cmd/lich-native/experimental_notice.go new file mode 100644 index 00000000..73d43479 --- /dev/null +++ b/native/cmd/lich-native/experimental_notice.go @@ -0,0 +1,75 @@ +package main + +import ( + "flag" + "log" + "sync/atomic" + + "gioui.org/layout" + "gioui.org/widget" + + "github.com/omartelo/lich/native/i18n" + "github.com/omartelo/lich/native/ui" +) + +var flagExperimentalNotice = flag.Bool("experimental-notice", false, "open the experimental-window notice at start, for -shot") + +// experimentalNoticeSeenKey is set once the notice was closed, a global +// setting: the notice greets the first native window a user opens, not every +// release that switches them to it. +const experimentalNoticeSeenKey = "native.experimentalNoticeSeen" + +// experimentalNotice tells a user the native window is experimental and asks +// for an issue on anything that breaks. +type experimentalNotice struct { + open atomic.Bool + dialog ui.DialogState + gotIt, report widget.Clickable +} + +// checkExperimentalNotice opens the notice when it was never closed. +func (u *window) checkExperimentalNotice() { + seen, err := u.client.GetSetting(u.ctx, experimentalNoticeSeenKey, "") + if err != nil { + log.Printf("experimental notice: %v", err) + return + } + if seen == "" { + u.beta.open.Store(true) + u.win.Invalidate() + } +} + +func (u *window) closeExperimentalNotice() { + u.beta.open.Store(false) + go func() { + u.logErr("experimental notice: record", u.client.SetSetting(u.ctx, experimentalNoticeSeenKey, "", "1")) + }() +} + +func (u *window) layoutExperimentalNotice(gtx C, th *ui.Theme) { + n := &u.beta + if n.gotIt.Clicked(gtx) { + u.closeExperimentalNotice() + } + if n.report.Clicked(gtx) { + u.closeExperimentalNotice() + go func() { + d, err := u.client.Diagnostics(u.ctx) + if err != nil { + log.Printf("experimental notice: diagnostics: %v", err) + return + } + u.logErr("experimental notice: open bug report", u.client.OpenExternal(u.ctx, bugReportURL(d))) + }() + } + on := th.On(th.Popover) + gotIt := on.Button(&n.gotIt, i18n.T("shell.experimentalNotice.gotIt")) + gotIt.Variant = ui.VariantGhost + d := th.Dialog(&n.dialog, n.open.Load(), i18n.T("shell.experimentalNotice.title"), i18n.T("shell.experimentalNotice.description")) + d.MaxWidth = optInWidth + d.Actions = []layout.Widget{gotIt.Layout, on.Button(&n.report, i18n.T("shell.experimentalNotice.reportIssue")).Layout} + if d.Layout(gtx) { + u.closeExperimentalNotice() + } +} diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index e5c401d7..d1c4dba6 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -135,6 +135,7 @@ type window struct { notifier *notifier optIn notifyOptIn gitMissing gitMissing + beta experimentalNotice setup providerSetup plugin pluginGate appUpdate appUpdateGate @@ -414,6 +415,10 @@ func (u *window) connect() error { go u.checkGit(false) } go u.settings.startupPatchNotes() + u.beta.open.Store(*flagExperimentalNotice) + if !*flagExperimentalNotice { + go u.checkExperimentalNotice() + } go u.pollAppUpdate() return nil } @@ -471,6 +476,7 @@ func (u *window) layout(gtx C) error { u.layoutClosePrompt(gtx, u.theme) u.layoutNotifyOptIn(gtx, u.theme) u.layoutGitMissing(gtx, u.theme) + u.layoutExperimentalNotice(gtx, u.theme) var edge int edge, act, contentErr = u.body(gtx, v) u.sidebar.flight(gtx, u.theme, u.model) diff --git a/native/i18n/locales/en/shell.json b/native/i18n/locales/en/shell.json index 83c1fa55..b0d4a943 100644 --- a/native/i18n/locales/en/shell.json +++ b/native/i18n/locales/en/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "Hands-on time", "handsOnFailed": "Could not read hands-on time." }, + "experimentalNotice": { + "title": "This window is experimental", + "description": "The native window is new and still experimental. If anything breaks or looks wrong, please open an issue and tell us: it is how we find out what to fix.", + "gotIt": "Got it", + "reportIssue": "Report an issue" + }, "gitMissingGate": { "title": "git is not installed", "description": "lich reads every branch, diff and worktree through git. Sessions still run without it — the version control surfaces stay empty.", diff --git a/native/i18n/locales/es/shell.json b/native/i18n/locales/es/shell.json index 1691f552..67626850 100644 --- a/native/i18n/locales/es/shell.json +++ b/native/i18n/locales/es/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "Tiempo activo", "handsOnFailed": "No se pudo leer el tiempo activo." }, + "experimentalNotice": { + "title": "Esta ventana es experimental", + "description": "La ventana nativa es nueva y todavía experimental. Si algo falla o se ve mal, por favor, abre un issue y cuéntanoslo: así sabemos qué corregir.", + "gotIt": "Entendido", + "reportIssue": "Reportar un problema" + }, "gitMissingGate": { "title": "git no está instalado", "description": "lich lee cada rama, diff y worktree a través de git. Las sesiones siguen funcionando sin él, pero las superficies de control de versiones quedan vacías.", diff --git a/native/i18n/locales/ja/shell.json b/native/i18n/locales/ja/shell.json index a66a9381..18b6b1ad 100644 --- a/native/i18n/locales/ja/shell.json +++ b/native/i18n/locales/ja/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "作業時間", "handsOnFailed": "作業時間を取得できませんでした。" }, + "experimentalNotice": { + "title": "このウィンドウは試験的です", + "description": "ネイティブウィンドウは新しく、まだ試験段階です。何かが壊れたり、おかしく見えたりしたら、ぜひ issue を作成して教えてください。修正すべき点を知る手がかりになります。", + "gotIt": "了解", + "reportIssue": "問題を報告" + }, "gitMissingGate": { "title": "git がインストールされていません", "description": "lich はブランチ、差分、ワークツリーをすべて git で読み取ります。git がなくてもセッションは動きますが、バージョン管理の画面は空のままです。", diff --git a/native/i18n/locales/ko/shell.json b/native/i18n/locales/ko/shell.json index 2b1a0aa3..593d6f93 100644 --- a/native/i18n/locales/ko/shell.json +++ b/native/i18n/locales/ko/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "직접 작업 시간", "handsOnFailed": "직접 작업 시간을 읽을 수 없습니다." }, + "experimentalNotice": { + "title": "이 창은 실험적입니다", + "description": "네이티브 창은 새로 나온 실험적 기능입니다. 문제가 생기거나 이상하게 보이면 꼭 이슈를 열어 알려 주세요. 무엇을 고쳐야 할지 알 수 있는 방법입니다.", + "gotIt": "확인", + "reportIssue": "문제 보고" + }, "gitMissingGate": { "title": "git이 설치되어 있지 않습니다", "description": "lich는 모든 브랜치, diff, worktree를 git으로 읽습니다. git이 없어도 세션은 실행되지만, 버전 관리 화면은 비어 있습니다.", diff --git a/native/i18n/locales/pt-br/shell.json b/native/i18n/locales/pt-br/shell.json index 84ec7bb0..15e6e831 100644 --- a/native/i18n/locales/pt-br/shell.json +++ b/native/i18n/locales/pt-br/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "Tempo de interação", "handsOnFailed": "Não foi possível ler o tempo de interação." }, + "experimentalNotice": { + "title": "Esta janela é experimental", + "description": "A janela nativa é nova e ainda experimental. Se algo quebrar ou parecer errado, por favor, abra uma issue e conte pra gente: é assim que descobrimos o que corrigir.", + "gotIt": "Entendi", + "reportIssue": "Reportar um problema" + }, "gitMissingGate": { "title": "O git não está instalado", "description": "O lich lê cada branch, diff e worktree pelo git. As sessões continuam funcionando sem ele, mas as telas de controle de versão ficam vazias.", diff --git a/native/i18n/locales/ru/shell.json b/native/i18n/locales/ru/shell.json index c1a95120..f2f6352a 100644 --- a/native/i18n/locales/ru/shell.json +++ b/native/i18n/locales/ru/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "Время за работой", "handsOnFailed": "Не удалось прочитать время за работой." }, + "experimentalNotice": { + "title": "Это окно экспериментальное", + "description": "Нативное окно новое и пока экспериментальное. Если что-то сломается или выглядит не так, пожалуйста, создайте issue и расскажите нам: так мы узнаём, что исправить.", + "gotIt": "Понятно", + "reportIssue": "Сообщить о проблеме" + }, "gitMissingGate": { "title": "git не установлен", "description": "lich читает все ветки, изменения и worktree через git. Сессии работают и без него, но разделы контроля версий остаются пустыми.", diff --git a/native/i18n/locales/zh-cn/shell.json b/native/i18n/locales/zh-cn/shell.json index 8552c93b..262412bc 100644 --- a/native/i18n/locales/zh-cn/shell.json +++ b/native/i18n/locales/zh-cn/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "亲自操作时长", "handsOnFailed": "无法读取亲自操作时长。" }, + "experimentalNotice": { + "title": "此窗口为实验性功能", + "description": "原生窗口是新功能,仍处于实验阶段。如果遇到任何故障或显示异常,请务必提交 issue 告诉我们:这是我们了解需要修复什么的方式。", + "gotIt": "知道了", + "reportIssue": "报告问题" + }, "gitMissingGate": { "title": "未安装 git", "description": "lich 通过 git 读取每个分支、差异和工作树。没有 git 时会话仍可运行,但版本控制相关界面会保持为空。",