diff --git a/go.mod b/go.mod index 7f20705..8ec5764 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module lazyrun -go 1.24 +go 1.26.0 require ( fyne.io/fyne/v2 v2.8.2 @@ -16,6 +16,7 @@ require ( github.com/anthonynsimon/bild v0.14.0 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect github.com/fyne-io/glfw-js v0.4.0 // indirect @@ -25,20 +26,28 @@ require ( github.com/go-text/render v0.2.1 // indirect github.com/go-text/typesetting v0.3.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.0 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.8.2 // indirect golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.60.1 // indirect ) diff --git a/go.sum b/go.sum index 6907866..59e8863 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdoh github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= @@ -34,6 +36,8 @@ github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2 github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= @@ -44,8 +48,12 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe9 github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= @@ -54,6 +62,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= @@ -70,6 +80,8 @@ golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -77,3 +89,11 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..7aec706 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,162 @@ +// Package db menyimpan daftar shortcut LazyRun dalam satu berkas SQLite (.lazy) +// supaya bisa dipindah antar komputer. +package db + +import ( + "database/sql" + "errors" + "fmt" + "os" + "strings" + "time" + + _ "modernc.org/sqlite" + + "lazyrun/internal/store" +) + +// FormatVersion dipakai untuk mengenali berkas .lazy buatan LazyRun. +const FormatVersion = "1" + +// DefaultName adalah nama berkas cadangan yang disarankan. +const DefaultName = "lazyrun-shortcuts.lazy" + +const schema = ` +CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE shortcuts ( + slug TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + kind TEXT, + url TEXT, + browser TEXT, + exec_args TEXT, + categories TEXT, + binary_path TEXT, + icon_path TEXT, + icon_source TEXT, + desktop_path TEXT, + desktop_copy_path TEXT, + menu INTEGER NOT NULL, + desktop INTEGER NOT NULL, + installed_at TEXT +);` + +// Export menulis seluruh daftar shortcut ke berkas SQLite baru. +func Export(path string, appVersion string, items []store.Shortcut) error { + if path == "" { + return errors.New("lokasi berkas ekspor kosong") + } + _ = os.Remove(path) // mulai dari berkas baru + + conn, err := sql.Open("sqlite", path) + if err != nil { + return err + } + defer conn.Close() + + if _, err := conn.Exec(schema); err != nil { + return err + } + + meta := map[string]string{ + "format": FormatVersion, + "app_version": appVersion, + "exported_at": time.Now().UTC().Format(time.RFC3339), + } + for key, value := range meta { + if _, err := conn.Exec("INSERT INTO meta(key, value) VALUES(?, ?)", key, value); err != nil { + return err + } + } + + tx, err := conn.Begin() + if err != nil { + return err + } + insert, err := tx.Prepare(`INSERT INTO shortcuts( + slug, name, type, kind, url, browser, exec_args, categories, + binary_path, icon_path, icon_source, desktop_path, desktop_copy_path, + menu, desktop, installed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + if err != nil { + tx.Rollback() + return err + } + defer insert.Close() + + for _, item := range items { + _, err := insert.Exec( + item.Slug, item.Name, item.Type, item.Kind, item.URL, item.Browser, + strings.Join(item.ExecArgs, "\x00"), item.Categories, + item.BinaryPath, item.IconPath, item.IconSource, item.DesktopPath, + item.DesktopCopyPath, boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop), + item.InstalledAt.Format(time.RFC3339), + ) + if err != nil { + tx.Rollback() + return err + } + } + return tx.Commit() +} + +// Import membaca daftar shortcut dari berkas .lazy. +func Import(path string) ([]store.Shortcut, error) { + if _, err := os.Stat(path); err != nil { + return nil, err + } + + conn, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + defer conn.Close() + + var format string + if err := conn.QueryRow("SELECT value FROM meta WHERE key = 'format'").Scan(&format); err != nil { + return nil, fmt.Errorf("bukan basis data LazyRun: %w", err) + } + if format != FormatVersion { + return nil, fmt.Errorf("versi basis data %s tidak didukung (mendukung %s)", format, FormatVersion) + } + + rows, err := conn.Query(`SELECT slug, name, type, kind, url, browser, exec_args, + categories, binary_path, icon_path, icon_source, desktop_path, + desktop_copy_path, menu, desktop, installed_at FROM shortcuts ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []store.Shortcut + for rows.Next() { + var item store.Shortcut + var args, installed string + var menu, desktop int + if err := rows.Scan(&item.Slug, &item.Name, &item.Type, &item.Kind, &item.URL, + &item.Browser, &args, &item.Categories, &item.BinaryPath, &item.IconPath, + &item.IconSource, &item.DesktopPath, &item.DesktopCopyPath, + &menu, &desktop, &installed); err != nil { + return nil, err + } + if args != "" { + item.ExecArgs = strings.Split(args, "\x00") + } + item.Targets = store.Targets{Menu: menu != 0, Desktop: desktop != 0} + if t, err := time.Parse(time.RFC3339, installed); err == nil { + item.InstalledAt = t + } + items = append(items, item) + } + return items, rows.Err() +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..74bd887 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,111 @@ +package db + +import ( + "os" + "path/filepath" + "testing" + "time" + + "lazyrun/internal/store" +) + +func sampleItems() []store.Shortcut { + return []store.Shortcut{ + { + Slug: "winbox", + Name: "Winbox", + Type: store.TypeExecutable, + Kind: "windows", + ExecArgs: []string{"/usr/bin/wine", "/home/u/.local/bin/winbox"}, + Categories: "Network;", + BinaryPath: "/home/u/.local/bin/winbox", + IconPath: "/home/u/.local/share/icons/winbox.png", + IconSource: "search:winbox", + DesktopPath: "/home/u/.local/share/applications/winbox.desktop", + Targets: store.Targets{Menu: true, Desktop: true}, + InstalledAt: time.Date(2026, 10, 13, 10, 0, 0, 0, time.UTC), + }, + { + Slug: "whatsapp-web", + Name: "WhatsApp Web", + Type: store.TypeWebApp, + Kind: "pwa", + URL: "https://web.whatsapp.com", + Browser: "flatpak run org.chromium.Chromium", + ExecArgs: []string{"flatpak", "run", "org.chromium.Chromium", "--app=https://web.whatsapp.com"}, + Categories: "Network;WebBrowser;", + DesktopPath: "/home/u/.local/share/applications/whatsapp-web.desktop", + DesktopCopyPath: "/home/u/Desktop/whatsapp-web.desktop", + Targets: store.Targets{Menu: true}, + InstalledAt: time.Date(2026, 10, 13, 10, 5, 0, 0, time.UTC), + }, + } +} + +func TestExportImportRoundtrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "db.lazy") + items := sampleItems() + + if err := Export(path, "1.2.0", items); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("berkas ekspor tidak ada: %v", err) + } + + got, err := Import(path) + if err != nil { + t.Fatal(err) + } + if len(got) != len(items) { + t.Fatalf("impor %d item, mau %d", len(got), len(items)) + } + + // urutan hasil impor berdasarkan nama: WhatsApp Web lalu Winbox + web, exe := got[0], got[1] + if web.Slug != "whatsapp-web" || web.URL != "https://web.whatsapp.com" || !web.Targets.Menu || web.Targets.Desktop { + t.Errorf("data webapp tidak utuh: %+v", web) + } + if exe.Slug != "winbox" || exe.Kind != "windows" || len(exe.ExecArgs) != 2 || exe.ExecArgs[0] != "/usr/bin/wine" { + t.Errorf("data executable tidak utuh: %+v", exe) + } + if exe.Categories != "Network;" || !exe.Targets.Desktop { + t.Errorf("kategori/target hilang: %+v", exe) + } + if !exe.InstalledAt.Equal(items[0].InstalledAt) { + t.Errorf("installed_at berubah: %v", exe.InstalledAt) + } +} + +func TestExportOverwritesOldFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "db.lazy") + if err := os.WriteFile(path, []byte("bukan sqlite"), 0o644); err != nil { + t.Fatal(err) + } + if err := Export(path, "1.2.0", sampleItems()[:1]); err != nil { + t.Fatal(err) + } + items, err := Import(path) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Errorf("isi setelah timpa: %d item", len(items)) + } +} + +func TestImportRejectsForeignFile(t *testing.T) { + dir := t.TempDir() + + plain := filepath.Join(dir, "catatan.lazy") + if err := os.WriteFile(plain, []byte("halo dunia"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Import(plain); err == nil { + t.Error("berkas bukan SQLite harus ditolak") + } + + if _, err := Import(filepath.Join(dir, "tidak-ada.lazy")); err == nil { + t.Error("berkas tidak ada harus error") + } +} diff --git a/internal/shortcut/edit.go b/internal/shortcut/edit.go new file mode 100644 index 0000000..8d3ee97 --- /dev/null +++ b/internal/shortcut/edit.go @@ -0,0 +1,206 @@ +package shortcut + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "lazyrun/internal/store" + "lazyrun/internal/xdg" +) + +// EditExecutable memperbarui shortcut executable yang sudah ada: +// binary bisa diganti (disalin ulang), begitu juga ikon, nama, dan lokasinya. +func EditExecutable(old store.Shortcut, o ExecOptions) (store.Shortcut, error) { + name := strings.TrimSpace(o.Name) + if err := validate(name, o.Menu, o.Desktop); err != nil { + return old, err + } + + src := o.BinaryPath + if src == "" { + src = old.BinaryPath + } + if err := checkRunnable(src); err != nil { + return old, fmt.Errorf("%s: %w", filepath.Base(src), err) + } + + sl := slugify(name) + binDst := filepath.Join(xdg.BinDir(), sl) + if sl != old.Slug { + if _, err := os.Stat(filepath.Join(xdg.ApplicationsDir(), sl+".desktop")); err == nil { + return old, fmt.Errorf("shortcut %q already exists", name) + } + } + + // salin kalau sumbernya bukan berkas yang sudah terpasang + if !samePath(src, binDst) { + if err := copyFile(src, binDst, 0o755); err != nil { + return old, err + } + } + + kind := DetectKind(binDst) + execArgs := []string{binDst} + if kind == KindWindows { + wine, err := exec.LookPath("wine") + if err != nil { + return old, errors.New("this is a Windows .exe; install wine first so the shortcut can run it") + } + execArgs = []string{wine, binDst} + } + + iconPath, iconSource := old.IconPath, old.IconSource + if o.IconPath != "" || o.IconURL != "" || len(o.IconData) > 0 { + path, source, err := installIconFor(iconOptions{ + IconPath: o.IconPath, IconURL: o.IconURL, IconData: o.IconData, + Query: name, Slug: sl, + }) + if err != nil { + return old, err + } + iconPath, iconSource = path, source + } + + category := category(o.Category, "Utility;") + if o.Category == "" { + category = old.Categories + if category == "" { + category = "Utility;" + } + } + + updated := old + updated.Slug = sl + updated.Name = name + updated.Kind = kind + updated.ExecArgs = execArgs + updated.Categories = category + updated.BinaryPath = binDst + updated.IconPath = iconPath + updated.IconSource = iconSource + updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop} + + if err := rewriteShortcutFiles(old, &updated, Entry{ + Name: name, + ExecArgs: execArgs, + Icon: iconPath, + Categories: category, + NoDisplay: !o.Menu, + }); err != nil { + return old, err + } + return updated, nil +} + +// EditWebApp memperbarui shortcut web app (URL, browser, ikon, nama, lokasi). +func EditWebApp(old store.Shortcut, o WebOptions) (store.Shortcut, error) { + name := strings.TrimSpace(o.Name) + if err := validate(name, o.Menu, o.Desktop); err != nil { + return old, err + } + if len(o.Browser.Exec) == 0 { + return old, errors.New("no browser selected") + } + pageURL, err := normalizeURL(o.URL) + if err != nil { + return old, err + } + + sl := slugify(name) + if sl != old.Slug { + if _, err := os.Stat(filepath.Join(xdg.ApplicationsDir(), sl+".desktop")); err == nil { + return old, fmt.Errorf("shortcut %q already exists", name) + } + } + + iconPath, iconSource := old.IconPath, old.IconSource + if o.IconPath != "" || o.IconURL != "" || len(o.IconData) > 0 { + path, source, err := installIconFor(iconOptions{ + IconPath: o.IconPath, IconURL: o.IconURL, IconData: o.IconData, + PageURL: pageURL, Query: name, Slug: sl, + }) + if err != nil { + return old, err + } + iconPath, iconSource = path, source + } + + appArgs := o.Browser.Exec[1:] + if o.Browser.Firefox { + appArgs = append(appArgs, "--new-window", pageURL) + } else { + appArgs = append(appArgs, "--app="+pageURL) + } + execArgs := append([]string{o.Browser.Exec[0]}, appArgs...) + + updated := old + updated.Slug = sl + updated.Name = name + updated.Kind = KindPWA + updated.URL = pageURL + updated.Browser = strings.Join(o.Browser.Exec, " ") + updated.ExecArgs = execArgs + updated.IconPath = iconPath + updated.IconSource = iconSource + updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop} + + if err := rewriteShortcutFiles(old, &updated, Entry{ + Name: name, + ExecArgs: execArgs, + Icon: iconPath, + Categories: old.Categories, + NoDisplay: !o.Menu, + }); err != nil { + return old, err + } + return updated, nil +} + +// rewriteShortcutFiles menulis ulang .desktop (dan salinannya) untuk shortcut +// yang diedit, termasuk membereskan berkas lama kalau nama/slug berubah. +// updated diisi lewat pointer karena path-nya berubah di sini. +func rewriteShortcutFiles(old store.Shortcut, updated *store.Shortcut, entry Entry) error { + if entry.Categories == "" { + entry.Categories = "Utility;" + } + + newDesk := filepath.Join(xdg.ApplicationsDir(), updated.Slug+".desktop") + if err := WriteDesktop(newDesk, entry); err != nil { + return err + } + updated.DesktopPath = newDesk + + // salinan Desktop: dibuat kalau diminta, dihapus kalau tidak + newCopy := "" + if updated.Targets.Desktop { + newCopy = filepath.Join(xdg.DesktopDir(), updated.Slug+".desktop") + if err := copyFile(newDesk, newCopy, 0o755); err != nil { + return err + } + } + updated.DesktopCopyPath = newCopy + + // bersihkan berkas lama kalau lokasinya berubah + if old.DesktopPath != "" && old.DesktopPath != newDesk { + _ = os.Remove(old.DesktopPath) + } + if old.DesktopCopyPath != "" && old.DesktopCopyPath != newCopy { + _ = os.Remove(old.DesktopCopyPath) + } + if old.IconPath != "" && old.IconPath != updated.IconPath { + _ = os.Remove(old.IconPath) + } + // binary lama dibuang kalau nama berkasnya berubah + if old.Type == store.TypeExecutable && old.BinaryPath != "" && old.BinaryPath != updated.BinaryPath { + if !samePath(old.BinaryPath, updated.BinaryPath) { + _ = os.Remove(old.BinaryPath) + } + } + + go RefreshCache() + return nil +} diff --git a/internal/shortcut/edit_test.go b/internal/shortcut/edit_test.go new file mode 100644 index 0000000..a249b5f --- /dev/null +++ b/internal/shortcut/edit_test.go @@ -0,0 +1,248 @@ +package shortcut + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEditExecutableRenamesAndMovesFiles(t *testing.T) { + home := testEnv(t) + src := fakeBinary(t, home, "old-tool") + + sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Old Tool", Category: "Utility", Menu: true, Desktop: true}) + if err != nil { + t.Fatal(err) + } + + updated, err := EditExecutable(sc, ExecOptions{ + BinaryPath: sc.BinaryPath, // binary tidak diganti + Name: "New Tool", + Category: "Network", + Menu: true, + Desktop: false, // salinan Desktop dimatikan + }) + if err != nil { + t.Fatal(err) + } + + if updated.Slug != "new-tool" || updated.BinaryPath != filepath.Join(home, ".local", "bin", "new-tool") { + t.Errorf("slug/binary baru salah: %+v", updated) + } + if _, err := os.Stat(updated.DesktopPath); err != nil { + t.Errorf(".desktop baru tidak ada: %v", err) + } + if !strings.HasSuffix(updated.DesktopPath, "new-tool.desktop") { + t.Errorf("nama .desktop = %s", updated.DesktopPath) + } + if updated.DesktopCopyPath != "" { + t.Errorf("salinan desktop harus kosong, dapat %s", updated.DesktopCopyPath) + } + // berkas lama sudah dibersihkan + if _, err := os.Stat(sc.DesktopPath); !os.IsNotExist(err) { + t.Errorf(".desktop lama masih ada") + } + if _, err := os.Stat(sc.DesktopCopyPath); !os.IsNotExist(err) { + t.Errorf("salinan desktop lama masih ada") + } + + content, err := os.ReadFile(updated.DesktopPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Name=New Tool\n") || !strings.Contains(string(content), "Categories=Network;\n") { + t.Errorf("isi .desktop salah:\n%s", content) + } + if !strings.Contains(string(content), "Exec="+updated.BinaryPath+"\n") { + t.Errorf("Exec tidak menunjuk binary baru:\n%s", content) + } +} + +func TestEditExecutableReplacesBinaryAndKind(t *testing.T) { + home := testEnv(t) + first := fakeBinary(t, home, "first") + second := filepath.Join(home, "second.sh") + if err := os.WriteFile(second, []byte("#!/bin/sh\necho hai\n"), 0o755); err != nil { + t.Fatal(err) + } + + sc, err := InstallExecutable(ExecOptions{BinaryPath: first, Name: "Tool", Menu: true}) + if err != nil { + t.Fatal(err) + } + if sc.Kind != KindBinary { + t.Fatalf("kind awal = %q", sc.Kind) + } + + updated, err := EditExecutable(sc, ExecOptions{BinaryPath: second, Name: "Tool", Menu: true}) + if err != nil { + t.Fatal(err) + } + if updated.Kind != KindScript { + t.Errorf("kind setelah ganti binary = %q, mau %q", updated.Kind, KindScript) + } + if updated.IconPath == "" { + t.Error("ikon harus tetap ada") + } +} + +func TestEditExecutableKeepsIconUnlessChanged(t *testing.T) { + home := testEnv(t) + src := fakeBinary(t, home, "tool") + iconFile := filepath.Join(home, "ikon.png") + if err := os.WriteFile(iconFile, append(pngMagic, []byte("ikon")...), 0o644); err != nil { + t.Fatal(err) + } + + sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool", Menu: true, IconPath: iconFile}) + if err != nil { + t.Fatal(err) + } + before := sc.IconPath + + updated, err := EditExecutable(sc, ExecOptions{BinaryPath: sc.BinaryPath, Name: "Tool", Menu: true}) + if err != nil { + t.Fatal(err) + } + if updated.IconPath != before { + t.Errorf("ikon berubah tanpa diminta: %s -> %s", before, updated.IconPath) + } + + // ganti ikon dengan berkas lain: nama berkasnya sama (slug sama), + // jadi yang diperiksa isinya + beforeBytes, err := os.ReadFile(before) + if err != nil { + t.Fatal(err) + } + other := filepath.Join(home, "lain.png") + if err := os.WriteFile(other, append(pngMagic, []byte("ikon lain yang lebih panjang")...), 0o644); err != nil { + t.Fatal(err) + } + updated, err = EditExecutable(sc, ExecOptions{BinaryPath: sc.BinaryPath, Name: "Tool", Menu: true, IconPath: other}) + if err != nil { + t.Fatal(err) + } + afterBytes, err := os.ReadFile(updated.IconPath) + if err != nil { + t.Fatal(err) + } + if string(beforeBytes) == string(afterBytes) { + t.Error("isi ikon tidak ikut berubah saat diganti") + } +} + +func TestEditWebAppChangesURLAndTargets(t *testing.T) { + testEnv(t) + sc, err := InstallWebApp(WebOptions{ + URL: "https://lama.example", + Name: "Contoh", + Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}}, + Menu: true, + }) + if err != nil { + t.Fatal(err) + } + + updated, err := EditWebApp(sc, WebOptions{ + URL: "lama.example/baru", + Name: "Contoh Baru", + Browser: BrowserArg{Name: "Firefox", Exec: []string{"/usr/bin/firefox"}, Firefox: true}, + Menu: false, + Desktop: true, + }) + if err != nil { + t.Fatal(err) + } + + if updated.URL != "https://lama.example/baru" { + t.Errorf("URL = %s", updated.URL) + } + if updated.DesktopCopyPath == "" { + t.Error("salinan desktop harus dibuat") + } + content, err := os.ReadFile(updated.DesktopPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Exec=/usr/bin/firefox --new-window https://lama.example/baru\n") { + t.Errorf("Exec salah:\n%s", content) + } + if !strings.Contains(string(content), "NoDisplay=true") { + t.Errorf("menu dimatikan harus NoDisplay:\n%s", content) + } +} + +func TestEditExecutableRejectsDuplicateName(t *testing.T) { + home := testEnv(t) + a := fakeBinary(t, home, "app-a") + b := fakeBinary(t, home, "app-b") + + if _, err := InstallExecutable(ExecOptions{BinaryPath: a, Name: "App A", Menu: true}); err != nil { + t.Fatal(err) + } + scB, err := InstallExecutable(ExecOptions{BinaryPath: b, Name: "App B", Menu: true}) + if err != nil { + t.Fatal(err) + } + + if _, err := EditExecutable(scB, ExecOptions{BinaryPath: scB.BinaryPath, Name: "App A", Menu: true}); err == nil { + t.Error("nama yang sudah dipakai harus ditolak") + } +} + +func TestEditWebAppRefreshesIconFromSite(t *testing.T) { + testEnv(t) + + page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + _, _ = w.Write([]byte(`
`)) + case "/paksa.png": + _, _ = w.Write(append(pngMagic, []byte("ikon paksa yang panjang")...)) + default: + _, _ = w.Write(append(pngMagic, []byte("ikon lama")...)) + } + })) + defer page.Close() + + sc, err := InstallWebApp(WebOptions{ + URL: page.URL, + Name: "Situs", + Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}}, + Menu: true, + }) + if err != nil { + t.Fatal(err) + } + + // ikon sebelum diedit (harus dibaca sebelum berkasnya ditimpa) + before, err := os.ReadFile(sc.IconPath) + if err != nil { + t.Fatal(err) + } + + // ubah URL ke halaman lain -> ikon lama harus diganti sumbernya + updated, err := EditWebApp(sc, WebOptions{ + URL: page.URL + "/halaman", + Name: "Situs", + Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}}, + IconURL: page.URL + "/paksa.png", + Menu: true, + }) + if err != nil { + t.Fatal(err) + } + if updated.IconSource != page.URL+"/paksa.png" { + t.Errorf("icon_source = %q", updated.IconSource) + } + after, err := os.ReadFile(updated.IconPath) + if err != nil { + t.Fatal(err) + } + if string(before) == string(after) { + t.Error("isi ikon harus diperbarui dari sumber baru") + } +} diff --git a/internal/shortcut/executable.go b/internal/shortcut/executable.go index 3dcf4a7..524fb33 100644 --- a/internal/shortcut/executable.go +++ b/internal/shortcut/executable.go @@ -94,6 +94,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { Type: store.TypeExecutable, Kind: kind, ExecArgs: entry.ExecArgs, + Categories: entry.Categories, BinaryPath: binDst, IconPath: iconPath, IconSource: iconSource, @@ -189,6 +190,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) { Type: store.TypeWebApp, Kind: KindPWA, URL: pageURL, + Categories: entry.Categories, Browser: strings.Join(o.Browser.Exec, " "), ExecArgs: entry.ExecArgs, IconPath: iconPath, diff --git a/internal/store/store.go b/internal/store/store.go index e38e037..de0c9ee 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -29,6 +29,7 @@ type Shortcut struct { URL string `json:"url,omitempty"` Browser string `json:"browser,omitempty"` ExecArgs []string `json:"exec_args,omitempty"` + Categories string `json:"categories,omitempty"` BinaryPath string `json:"binary_path,omitempty"` IconPath string `json:"icon_path,omitempty"` IconSource string `json:"icon_source,omitempty"` diff --git a/internal/ui/dialog_edit.go b/internal/ui/dialog_edit.go new file mode 100644 index 0000000..ab3c62b --- /dev/null +++ b/internal/ui/dialog_edit.go @@ -0,0 +1,277 @@ +package ui + +import ( + "fmt" + "path/filepath" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "lazyrun/internal/browser" + "lazyrun/internal/db" + "lazyrun/internal/icon" + "lazyrun/internal/shortcut" + "lazyrun/internal/store" +) + +var lazyFilter = storage.NewExtensionFileFilter([]string{".lazy"}) + +// exportShortcuts menyimpan daftar shortcut ke satu berkas SQLite (.lazy), +// mis. "db.lazy", supaya bisa dipindah ke komputer lain. +func (ui *app) exportShortcuts() { + save := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) { + if err != nil { + dialog.ShowError(err, ui.win) + return + } + if w == nil { + return + } + path := w.URI().Path() + w.Close() // berkas kosong dari dialog akan diganti basis data + if !strings.HasSuffix(strings.ToLower(path), ".lazy") { + path += ".lazy" + } + + if err := db.Export(path, ui.version, ui.store.Items); err != nil { + dialog.ShowError(err, ui.win) + return + } + dialog.ShowInformation(T("export"), fmt.Sprintf(T("export_done"), len(ui.store.Items), path), ui.win) + }, ui.win) + save.SetFileName(db.DefaultName) + save.SetFilter(lazyFilter) + save.Show() +} + +// importShortcuts menambahkan shortcut dari berkas .lazy tanpa menimpa yang sudah ada. +func (ui *app) importShortcuts() { + open := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { + if err != nil { + dialog.ShowError(err, ui.win) + return + } + if r == nil { + return + } + path := r.URI().Path() + r.Close() + + items, err := db.Import(path) + if err != nil { + dialog.ShowError(err, ui.win) + return + } + if len(items) == 0 { + dialog.ShowInformation(T("import"), T("import_empty"), ui.win) + return + } + + added, skipped := mergeImported(ui.store, items) + if err := ui.store.Save(); err != nil { + dialog.ShowError(err, ui.win) + return + } + ui.reload() + dialog.ShowInformation(T("import"), fmt.Sprintf(T("import_done"), added, skipped), ui.win) + }, ui.win) + open.SetFilter(lazyFilter) + open.Show() +} + +// showEditDialog mengubah shortcut yang sudah ada: binary/URL, ikon, nama, lokasi. +func (ui *app) showEditDialog(sc store.Shortcut) { + isWeb := sc.Type == store.TypeWebApp + + var iconPath, iconURL, binPath string + var iconData []byte + + nameEntry := widget.NewEntry() + nameEntry.SetText(sc.Name) + iconLabel := muted(iconLabelOf(sc)) + menu := widget.NewCheck(T("check_menu"), nil) + menu.SetChecked(sc.Targets.Menu) + desktopChk := widget.NewCheck(T("check_desktop"), nil) + desktopChk.SetChecked(sc.Targets.Desktop) + + browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { + pickFile(ui.win, imageFilter, func(path string) { + iconPath, iconURL, iconData = path, "", nil + iconLabel.SetText(filepath.Base(path)) + }) + }) + onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() { + ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { + iconURL, iconPath, iconData = result.URL, "", data + iconLabel.SetText(result.Name) + }) + }) + + // widget khusus executable + binLabel := muted(sc.BinaryPath) + browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { + pickFile(ui.win, nil, func(path string) { + binPath = path + binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path)) + }) + }) + category := widget.NewSelect(categories, nil) + category.SetSelected(categoryOf(sc)) + + // widget khusus web app + urlEntry := widget.NewEntry() + urlEntry.SetText(sc.URL) + browserSel := widget.NewSelect(browserNames(ui.browsers), nil) + browserSel.SetSelected(currentBrowser(ui.browsers, sc.Browser)) + + iconItem := widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, + container.NewHBox(onlineIcon, browseIcon), iconLabel)) + + items := []*widget.FormItem{} + if isWeb { + items = append(items, + widget.NewFormItem(T("field_url"), urlEntry), + widget.NewFormItem(T("field_name"), nameEntry), + iconItem, + widget.NewFormItem(T("field_browser"), browserSel), + ) + } else { + items = append(items, + widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)), + widget.NewFormItem(T("field_name"), nameEntry), + iconItem, + widget.NewFormItem(T("field_category"), category), + ) + } + + var d *dialog.CustomDialog + save := newButton(T("save"), theme.DocumentSaveIcon(), T("tip_save"), widget.HighImportance, func() { + switch { + case strings.TrimSpace(nameEntry.Text) == "": + dialog.ShowInformation(T("err_title"), T("need_name"), ui.win) + return + case !menu.Checked && !desktopChk.Checked: + dialog.ShowInformation(T("err_title"), T("need_target"), ui.win) + return + } + + ui.install(func() (store.Shortcut, error) { + if isWeb { + b := ui.browsers[indexOf(ui.browsers, browserSel.Selected)] + return shortcut.EditWebApp(sc, shortcut.WebOptions{ + URL: urlEntry.Text, + Name: nameEntry.Text, + Browser: shortcut.BrowserArg{Name: b.Name, Exec: b.Exec, Firefox: b.Kind == browser.Firefox}, + IconPath: iconPath, + IconURL: iconURL, + IconData: iconData, + Menu: menu.Checked, + Desktop: desktopChk.Checked, + }) + } + path := binPath + if path == "" { + path = sc.BinaryPath + } + return shortcut.EditExecutable(sc, shortcut.ExecOptions{ + BinaryPath: path, + IconPath: iconPath, + IconURL: iconURL, + IconData: iconData, + Name: nameEntry.Text, + Category: category.Selected, + Menu: menu.Checked, + Desktop: desktopChk.Checked, + }) + }, func(updated store.Shortcut, err error) { + if err != nil { + dialog.ShowError(err, ui.win) + return + } + ui.store.Add(updated) + if err := ui.store.Save(); err != nil { + dialog.ShowError(err, ui.win) + return + } + d.Hide() + ui.reload() + if card, ok := ui.cards[updated.Slug]; ok { + card.flash(0) + } + dialog.ShowInformation(T("edit_title"), fmt.Sprintf(T("edit_saved"), updated.Name), ui.win) + }) + }) + + hint := textLine(T("edit_hint"), 12, false, colorNameMuted) + content := container.NewVScroll(container.NewVBox( + closeRow(func() { d.Hide() }), + widget.NewForm(items...), + widget.NewSeparator(), + sectionTitle(T("section_location")), + menu, + desktopChk, + hint, + container.NewHBox(logoBox(newLogo(), 20), spacer(), + newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }), + save), + )) + + d = dialog.NewCustomWithoutButtons(T("edit_title"), content, ui.win) + d.SetOnClosed(pushHintTarget(hint)) + showDialog(d, ui.win, 560, 520) +} + +// mergeImported menambahkan shortcut hasil impor tanpa menimpa yang sudah ada. +func mergeImported(target *store.Store, items []store.Shortcut) (added, skipped int) { + for _, item := range items { + item.Name = strings.TrimSpace(item.Name) + if item.Name == "" || item.Slug == "" { + skipped++ + continue + } + if _, exists := target.Get(item.Slug); exists { + skipped++ + continue + } + target.Add(item) + added++ + } + return added, skipped +} + +// iconLabelOf menampilkan ikon yang sedang dipakai shortcut. +func iconLabelOf(sc store.Shortcut) string { + if sc.IconPath == "" { + return T("icon_auto") + } + return filepath.Base(sc.IconPath) +} + +// currentBrowser mengembalikan nama browser yang cocok dengan yang tersimpan. +func currentBrowser(list []browser.Browser, saved string) string { + for _, b := range list { + if strings.Join(b.Exec, " ") == saved { + return b.Name + } + } + if len(list) > 0 { + return list[0].Name + } + return "" +} + +// categoryOf mengambil kategori pertama yang tersimpan. +func categoryOf(sc store.Shortcut) string { + first, _, _ := strings.Cut(strings.TrimSuffix(sc.Categories, ";"), ";") + for _, c := range categories { + if c == first { + return c + } + } + return "Utility" +} diff --git a/internal/ui/dialog_executable.go b/internal/ui/dialog_executable.go index 0ed16fa..e964009 100644 --- a/internal/ui/dialog_executable.go +++ b/internal/ui/dialog_executable.go @@ -41,7 +41,7 @@ func (ui *app) showExecutableDialog() { desktop := widget.NewCheck(T("check_desktop"), nil) desktop.SetChecked(true) - browseBin := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { + browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { pickFile(ui.win, nil, func(path string) { binPath = path binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path)) @@ -50,13 +50,13 @@ func (ui *app) showExecutableDialog() { } }) }) - browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { + browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { pickFile(ui.win, imageFilter, func(path string) { iconPath, iconURL, iconData = path, "", nil iconLabel.SetText(filepath.Base(path)) }) }) - onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() { + onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() { ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { iconURL, iconPath, iconData = result.URL, "", data iconLabel.SetText(result.Name) @@ -64,7 +64,7 @@ func (ui *app) showExecutableDialog() { }) var d *dialog.CustomDialog - install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() { + install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() { switch { case binPath == "": dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win) @@ -91,8 +91,6 @@ func (ui *app) showExecutableDialog() { func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) }, ) }) - install.Importance = widget.HighImportance - form := widget.NewForm( widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)), widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, @@ -100,6 +98,7 @@ func (ui *app) showExecutableDialog() { widget.NewFormItem(T("field_name"), nameEntry), widget.NewFormItem(T("field_category"), category), ) + hint := textLine(T("exec_hint"), 12, false, colorNameMuted) content := container.NewVScroll(container.NewVBox( closeRow(func() { d.Hide() }), form, @@ -107,13 +106,14 @@ func (ui *app) showExecutableDialog() { sectionTitle(T("section_location")), menu, desktop, - muted(T("exec_hint")), + hint, container.NewHBox(logoBox(newLogo(), 20), spacer(), - widget.NewButton(T("cancel"), func() { d.Hide() }), + newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }), install), )) d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win) + d.SetOnClosed(pushHintTarget(hint)) // keterangan tombol muncul di baris hint dialog ini showDialog(d, ui.win, 560, 520) } diff --git a/internal/ui/dialog_icon.go b/internal/ui/dialog_icon.go index cc2f809..609ec03 100644 --- a/internal/ui/dialog_icon.go +++ b/internal/ui/dialog_icon.go @@ -87,10 +87,11 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { entry.OnSubmitted = func(string) { search() } // status di barisnya sendiri: supaya tidak bertabrakan dengan tombol // di layar sempit atau ber-penskalaan besar. - bottom := container.NewVBox(status, container.NewHBox( + hint := textLine(T("icon_search_hint"), 12, false, colorNameMuted) + bottom := container.NewVBox(hint, status, container.NewHBox( spacer(), - widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }), - widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search), + newButton(T("back"), theme.NavigateBackIcon(), T("tip_back"), widget.MediumImportance, func() { d.Hide() }), + newButton(T("search"), theme.SearchIcon(), T("tip_search"), widget.HighImportance, search), )) content := container.NewBorder( container.NewVBox(closeRow(func() { d.Hide() }), entry), @@ -99,6 +100,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { ) d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win) + d.SetOnClosed(pushHintTarget(hint)) showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog search() } @@ -126,7 +128,7 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte), var mu sync.Mutex var raw []byte - button := widget.NewButton("", func() { + button := newButton("", nil, result.Name, widget.LowImportance, func() { mu.Lock() picked := raw mu.Unlock() @@ -135,8 +137,8 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte), d.Hide() } }) - button.Importance = widget.LowImportance button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh + button.Refresh() go func() { slot <- struct{}{} diff --git a/internal/ui/dialog_test.go b/internal/ui/dialog_test.go index c5ee1bd..b374711 100644 --- a/internal/ui/dialog_test.go +++ b/internal/ui/dialog_test.go @@ -11,6 +11,7 @@ import ( "fyne.io/fyne/v2/test" "lazyrun/internal/icon" + "lazyrun/internal/store" ) // TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom, @@ -62,6 +63,17 @@ func TestDialogsOpenWithoutPanic(t *testing.T) { t.Fatal("modal web app tidak muncul") } } + ui.showEditDialog(store.Shortcut{Slug: "winbox", Name: "Winbox", Type: store.TypeExecutable, Kind: "windows", + BinaryPath: "/tmp/winbox", IconPath: "", Categories: "Network;", Targets: store.Targets{Menu: true, Desktop: true}}) + if top := ui.win.Canvas().Overlays().Top(); top == nil { + t.Fatal("modal edit executable tidak muncul") + } + ui.showEditDialog(store.Shortcut{Slug: "wa", Name: "WhatsApp Web", Type: store.TypeWebApp, Kind: "pwa", + URL: "https://web.whatsapp.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}}) + if top := ui.win.Canvas().Overlays().Top(); top == nil { + t.Fatal("modal edit webapp tidak muncul") + } + ui.showIconSearch("whatsapp", func(icon.Result, []byte) {}) if top := ui.win.Canvas().Overlays().Top(); top == nil { t.Fatal("modal pencarian ikon tidak muncul") @@ -108,3 +120,27 @@ func TestStringsCoverUsedKeys(t *testing.T) { } } } + +func TestMergeImported(t *testing.T) { + target := &store.Store{} + target.Add(store.Shortcut{Slug: "winbox", Name: "Winbox"}) + + added, skipped := mergeImported(target, []store.Shortcut{ + {Slug: "winbox", Name: "Winbox lama"}, // sudah ada -> dilewati + {Slug: "figma", Name: "Figma"}, // baru + {Slug: "", Name: "Tanpa slug"}, // rusak -> dilewati + {Slug: "kosong", Name: " "}, // tanpa nama -> dilewati + {Slug: "deploy", Name: " deploy.sh "}, // nama dirapikan + }) + + if added != 2 || skipped != 3 { + t.Errorf("added=%d skipped=%d, mau 2 dan 3", added, skipped) + } + if len(target.Items) != 3 { + t.Errorf("jumlah item = %d", len(target.Items)) + } + dep, ok := target.Get("deploy") + if !ok || target.Items[dep].Name != "deploy.sh" { + t.Errorf("nama item tidak dirapikan: %+v", target.Items) + } +} diff --git a/internal/ui/dialog_webapp.go b/internal/ui/dialog_webapp.go index 9438387..b9774b7 100644 --- a/internal/ui/dialog_webapp.go +++ b/internal/ui/dialog_webapp.go @@ -54,13 +54,13 @@ func (ui *app) showWebAppDialog() { } } - browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { + browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { pickFile(ui.win, imageFilter, func(path string) { iconPath, iconURL, iconData = path, "", nil iconLabel.SetText(filepath.Base(path)) }) }) - onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() { + onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() { ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { iconURL, iconPath, iconData = result.URL, "", data iconLabel.SetText(result.Name) @@ -68,7 +68,7 @@ func (ui *app) showWebAppDialog() { }) var d *dialog.CustomDialog - install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() { + install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() { switch { case strings.TrimSpace(urlEntry.Text) == "": dialog.ShowInformation(T("err_title"), T("need_url"), ui.win) @@ -96,8 +96,6 @@ func (ui *app) showWebAppDialog() { func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) }, ) }) - install.Importance = widget.HighImportance - form := widget.NewForm( widget.NewFormItem(T("field_url"), urlEntry), widget.NewFormItem(T("field_name"), nameEntry), @@ -105,6 +103,7 @@ func (ui *app) showWebAppDialog() { widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, container.NewHBox(onlineIcon, browseIcon), iconLabel)), ) + hint := textLine(T("web_hint"), 12, false, colorNameMuted) content := container.NewVScroll(container.NewVBox( closeRow(func() { d.Hide() }), form, @@ -112,13 +111,14 @@ func (ui *app) showWebAppDialog() { sectionTitle(T("section_location")), menu, desktop, - muted(T("web_hint")), + hint, container.NewHBox(logoBox(newLogo(), 20), spacer(), - widget.NewButton(T("cancel"), func() { d.Hide() }), + newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }), install), )) d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win) + d.SetOnClosed(pushHintTarget(hint)) showDialog(d, ui.win, 560, 520) } diff --git a/internal/ui/main_window.go b/internal/ui/main_window.go index bbef443..16f3014 100644 --- a/internal/ui/main_window.go +++ b/internal/ui/main_window.go @@ -32,6 +32,7 @@ type app struct { icons *iconCache count *canvas.Text // angka jumlah shortcut di header daftar versionText *canvas.Text // versi di footer; berubah kalau ada update + hint *canvas.Text // keterangan tombol yang sedang disorot accent *canvas.LinearGradient version string } @@ -40,6 +41,7 @@ type app struct { func Run(a fyne.App, version string) { ui, loadErr := build(a, version) ui.win.Show() + pushHintTarget(ui.hint) // target default: baris hint di footer ui.flashCards() ui.scheduleUpdateCheck() if loadErr != nil { @@ -67,9 +69,8 @@ func build(a fyne.App, version string) (*app, error) { st, err := store.Load(xdg.MetadataPath()) ui.store = st - addExec := widget.NewButtonWithIcon(T("btn_executable"), theme.ContentAddIcon(), ui.showExecutableDialog) - addExec.Importance = widget.HighImportance - addWeb := widget.NewButtonWithIcon(T("btn_webapp"), theme.ContentAddIcon(), ui.showWebAppDialog) + addExec := newButton(T("btn_executable"), theme.ContentAddIcon(), T("tip_executable"), widget.HighImportance, ui.showExecutableDialog) + addWeb := newButton(T("btn_webapp"), theme.ContentAddIcon(), T("tip_webapp"), widget.MediumImportance, ui.showWebAppDialog) leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox( heading("LazyRun"), @@ -78,14 +79,17 @@ func build(a fyne.App, version string) (*app, error) { rightHeader := container.NewHBox(addExec, addWeb) header := container.NewBorder(nil, nil, leftHeader, rightHeader) - update := widget.NewButtonWithIcon(T("check_updates"), theme.DownloadIcon(), ui.checkUpdates) - github := widget.NewButton(T("github"), func() { ui.openURL(repoURL) }) - donate := widget.NewButtonWithIcon(T("donate"), - fyne.NewStaticResource("heart.png", assets.Heart), func() { ui.openURL(donateURL) }) + update := newButton(T("check_updates"), theme.DownloadIcon(), T("tip_check_updates"), widget.MediumImportance, ui.checkUpdates) + github := newButton(T("github"), nil, T("tip_github"), widget.MediumImportance, func() { ui.openURL(repoURL) }) + donate := newButton(T("donate"), fyne.NewStaticResource("heart.png", assets.Heart), T("tip_donate"), widget.MediumImportance, func() { ui.openURL(donateURL) }) + export := newIconButton(theme.DocumentSaveIcon(), T("tip_export"), ui.exportShortcuts) + importBtn := newIconButton(theme.StorageIcon(), T("tip_import"), ui.importShortcuts) + ui.hint = caption("") footer := container.NewBorder( widget.NewSeparator(), nil, - container.NewHBox(update, github, donate), + container.NewHBox(update, export, importBtn, github, donate), ui.versionText, + ui.hint, ) ui.accent = accentBar() @@ -145,16 +149,17 @@ func (ui *app) row(sc store.Shortcut) *hoverCard { } actions := container.NewHBox(chips...) - actions.Add(iconButton(theme.MediaPlayIcon(), func() { ui.launch(sc) })) + actions.Add(newIconButton(theme.MediaPlayIcon(), T("tip_run"), func() { ui.launch(sc) })) if syncable(sc) { - actions.Add(iconButton(theme.ViewRefreshIcon(), func() { ui.syncIcon(sc) })) + actions.Add(newIconButton(theme.ViewRefreshIcon(), T("tip_sync"), func() { ui.syncIcon(sc) })) } - actions.Add(iconButton(theme.FolderOpenIcon(), func() { + actions.Add(newIconButton(theme.DocumentCreateIcon(), T("tip_edit"), func() { ui.showEditDialog(sc) })) + actions.Add(newIconButton(theme.FolderOpenIcon(), T("tip_folder"), func() { if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil { dialog.ShowError(err, ui.win) } })) - actions.Add(iconButton(theme.DeleteIcon(), func() { ui.confirmDelete(sc) })) + actions.Add(newIconButton(theme.DeleteIcon(), T("tip_delete"), func() { ui.confirmDelete(sc) })) return newHoverCard(container.NewBorder(nil, nil, container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info), @@ -239,12 +244,11 @@ func syncable(sc store.Shortcut) bool { // syncIcon mengunduh ulang ikon shortcut dari sumber online-nya. func (ui *app) syncIcon(sc store.Shortcut) { - progress := dialog.NewProgressInfinite(T("sync_icon"), T("working"), ui.win) - progress.Show() + hide := ui.showLoading(T("sync_icon"), T("working")) go func() { path, err := shortcut.RefreshIcon(sc) fyne.Do(func() { - progress.Hide() + hide() if err != nil { dialog.ShowError(err, ui.win) return @@ -292,14 +296,14 @@ func (ui *app) confirmDelete(sc store.Shortcut) { d.Show() } -// install menjalankan pekerjaan berat (salin file besar / fetch favicon) di luar UI thread. +// install menjalankan pekerjaan berat (salin file besar / unduh ikon) di luar UI thread, +// dengan dialog proses berisi progress bar. func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) { - progress := dialog.NewProgressInfinite(T("install"), T("working"), ui.win) - progress.Show() + hide := ui.showLoading(T("install"), T("working")) go func() { sc, err := work() fyne.Do(func() { - progress.Hide() + hide() done(sc, err) }) }() diff --git a/internal/ui/strings.go b/internal/ui/strings.go index 67472d0..2483af6 100644 --- a/internal/ui/strings.go +++ b/internal/ui/strings.go @@ -25,12 +25,14 @@ var tr = map[string][2]string{ "searching": {"Searching...", "Mencari..."}, "search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."}, "search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."}, + "icon_search_hint": {"Hover a button to see what it does.", "Sorot tombol untuk melihat fungsinya."}, "search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."}, "icon_search_title": {"Icon from the internet", "Ikon dari internet"}, "sync_icon": {"Sync icon", "Sinkron ikon"}, "browse": {"Browse...", "Pilih..."}, "install": {"Install", "Install"}, "cancel": {"Cancel", "Batal"}, + "save": {"Save", "Simpan"}, "back": {"Back", "Kembali"}, "delete": {"Delete", "Hapus"}, "working": {"Working...", "Sedang diproses..."}, @@ -78,6 +80,40 @@ var tr = map[string][2]string{ "update_restart": {"Restart now", "Restart sekarang"}, "up_to_date": {"LazyRun %s is the latest version.", "LazyRun %s adalah versi terbaru."}, "dev_build": {"This is a development build (%s), update check is skipped.", "Ini build development (%s), cek update dilewati."}, + + "export": {"Export", "Ekspor"}, + "import": {"Import", "Impor"}, + "edit_title": {"Edit shortcut", "Edit shortcut"}, + "edit_saved": {"%s updated.", "%s diperbarui."}, + "edit_hint": {"Change the binary, icon, name, or where the shortcut appears.", "Ubah binary, ikon, nama, atau lokasi shortcutnya."}, + "import_done": {"%d shortcuts imported (%d skipped).", "%d shortcut diimpor (%d dilewati)."}, + "import_empty": {"No shortcuts found in this file.", "Tidak ada shortcut di berkas ini."}, + "export_done": {"Exported %d shortcuts to\n%s", "%d shortcut diekspor ke\n%s"}, + + "tip_executable": {"Create a shortcut from a binary", "Buat shortcut dari berkas binary"}, + "tip_webapp": {"Create a shortcut from a web address", "Buat shortcut dari alamat web"}, + "tip_run": {"Run this shortcut", "Jalankan shortcut ini"}, + "tip_sync": {"Download the icon again from its source", "Unduh ulang ikonnya dari sumber"}, + "tip_edit": {"Edit: binary, icon, name, location", "Edit: binary, ikon, nama, lokasi"}, + "tip_folder": {"Open the .desktop folder", "Buka folder .desktop"}, + "tip_delete": {"Remove this shortcut", "Hapus shortcut ini"}, + "tip_check_updates": {"Check for a newer release", "Periksa rilis terbaru"}, + "tip_github": {"Open the LazyRun repository", "Buka repo LazyRun"}, + "tip_donate": {"Support LazyRun development", "Dukung pengembangan LazyRun"}, + "tip_export": {"Export the shortcut list to a .lazy database", "Ekspor daftar shortcut ke basis data .lazy"}, + "tip_import": {"Import shortcuts from a .lazy database", "Impor shortcut dari basis data .lazy"}, + "tip_close": {"Close this dialog", "Tutup dialog ini"}, + "tip_back": {"Back to the previous dialog", "Kembali ke dialog sebelumnya"}, + "tip_search": {"Search icons online", "Cari ikon di internet"}, + "tip_browse": {"Pick a file", "Pilih berkas"}, + "tip_online": {"Pick an icon from the internet", "Pilih ikon dari internet"}, + "tip_install": {"Install the shortcut", "Pasang shortcutnya"}, + "tip_cancel": {"Cancel without saving", "Batal tanpa menyimpan"}, + "tip_save": {"Save the changes", "Simpan perubahan"}, + "tip_update_now": {"Download and install the update", "Unduh dan pasang update"}, + "tip_update_page": {"Open the release page in the browser", "Buka halaman rilis di browser"}, + "tip_later": {"Ask me later", "Ingatkan nanti"}, + "tip_restart": {"Restart LazyRun with the new version", "Jalankan ulang LazyRun dengan versi baru"}, } var lang = detectLang() diff --git a/internal/ui/style.go b/internal/ui/style.go index 2d70d7d..2540871 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -83,13 +83,6 @@ func accentBar() *canvas.LinearGradient { return bar } -// iconButton adalah tombol ikon tanpa label, gaya datar. -func iconButton(icon fyne.Resource, tapped func()) *widget.Button { - button := widget.NewButtonWithIcon("", icon, tapped) - button.Importance = widget.LowImportance - return button -} - // thumb menampilkan ikon shortcut, atau fallback kalau file ikonnya hilang. func thumb(path string, fallback fyne.Resource) fyne.CanvasObject { var image *canvas.Image @@ -122,7 +115,7 @@ func logoBox(image *canvas.Image, size float32) fyne.CanvasObject { // closeRow menempatkan tombol tutup di kanan atas isi dialog, supaya // selalu ada jalan keluar yang terlihat walau tombol bawah terpotong. func closeRow(hide func()) fyne.CanvasObject { - return container.NewHBox(spacer(), iconButton(theme.CancelIcon(), hide)) + return container.NewHBox(spacer(), newIconButton(theme.CancelIcon(), T("tip_close"), hide)) } // showDialog menampilkan dialog dengan ukuran yang disesuaikan ke window. diff --git a/internal/ui/tooltip.go b/internal/ui/tooltip.go new file mode 100644 index 0000000..8c2ab0e --- /dev/null +++ b/internal/ui/tooltip.go @@ -0,0 +1,107 @@ +package ui + +import ( + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/widget" +) + +// Keterangan tombol muncul di satu baris hint (bawah window atau dalam dialog), +// bukan sebagai popup: popup Fyne selalu jadi overlay, dan selama overlay ada +// semua klik ke konten di bawahnya tidak diteruskan (tombol jadi perlu 2x klik). +var ( + hintMu sync.Mutex + hintTarget *canvas.Text +) + +// pushHintTarget mengarahkan keterangan hover ke label tertentu (dipakai dialog). +// Fungsi yang dikembalikan mengembalikan target sebelumnya. +func pushHintTarget(label *canvas.Text) func() { + hintMu.Lock() + previous := hintTarget + hintTarget = label + hintMu.Unlock() + + return func() { + hintMu.Lock() + hintTarget = previous + hintMu.Unlock() + } +} + +func showHint(text string) { + hintMu.Lock() + target := hintTarget + hintMu.Unlock() + + if target == nil || text == "" { + return + } + target.Text = text + target.Refresh() +} + +// clearHint menghapus hint hanya kalau isinya memang milik tombol ini. +func clearHint(text string) { + hintMu.Lock() + target := hintTarget + hintMu.Unlock() + + if target == nil || target.Text != text { + return + } + target.Text = "" + target.Refresh() +} + +// tipButton adalah tombol yang menampilkan fungsinya saat disorot mouse. +type tipButton struct { + widget.Button + tip string +} + +// newButton membuat tombol (dengan ikon kalau diberikan) plus keterangan hover. +func newButton(text string, icon fyne.Resource, tip string, importance widget.Importance, tapped func()) *tipButton { + button := &tipButton{tip: tip} + button.Text = text + button.Icon = icon + button.Importance = importance + button.OnTapped = tapped + button.ExtendBaseWidget(button) + return button +} + +// newIconButton adalah tombol ikon saja (tanpa teks), gaya datar. +func newIconButton(icon fyne.Resource, tip string, tapped func()) *tipButton { + return newButton("", icon, tip, widget.LowImportance, tapped) +} + +func (b *tipButton) MouseIn(event *desktop.MouseEvent) { + b.Button.MouseIn(event) + showHint(b.tip) +} + +func (b *tipButton) MouseOut() { + b.Button.MouseOut() + clearHint(b.tip) +} + +// showLoading menampilkan dialog proses dengan progress bar yang berjalan, +// dan mengembalikan fungsi untuk menutupnya. +func (ui *app) showLoading(title, message string) func() { + bar := widget.NewProgressBarInfinite() + + var d *dialog.CustomDialog + d = dialog.NewCustomWithoutButtons(title, container.NewBorder( + container.NewVBox(logoBox(newLogo(), 22), muted(message)), + nil, nil, nil, + bar, + ), ui.win) + showDialog(d, ui.win, 420, 170) + return d.Hide +} diff --git a/internal/ui/tooltip_test.go b/internal/ui/tooltip_test.go new file mode 100644 index 0000000..4b3fcd7 --- /dev/null +++ b/internal/ui/tooltip_test.go @@ -0,0 +1,89 @@ +package ui + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +func TestTipButtonShowsAndHidesHint(t *testing.T) { + a := test.NewApp() + a.Settings().SetTheme(brand{theme.DefaultTheme()}) + defer a.Quit() + + hint := textLine("", 12, false, colorNameMuted) + button := newIconButton(theme.MediaPlayIcon(), "jalankan shortcut", func() {}) + w := a.NewWindow("tip") + w.SetContent(container.NewBorder(nil, hint, nil, nil, button)) + w.Resize(fyne.NewSize(300, 200)) + w.Show() + restore := pushHintTarget(hint) + defer restore() + + if hint.Text != "" { + t.Fatal("hint muncul tanpa hover") + } + + test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20)) + if hint.Text != "jalankan shortcut" { + t.Errorf("hint saat hover = %q", hint.Text) + } + + // keluar dari tombol: hint dibersihkan + test.MoveMouse(w.Canvas(), fyne.NewPos(280, 180)) + if hint.Text != "" { + t.Errorf("hint masih %q setelah kursor keluar", hint.Text) + } +} + +// klik saat tooltip terbuka harus tetap kena tombolnya (dulu PopUp menelan klik). +// klik saat hint tampil harus tetap kena tombolnya (dulu pakai popup yang +// jadi overlay, sehingga klik pertama selalu tertelan). +func TestTipButtonClickGoesThrough(t *testing.T) { + a := test.NewApp() + a.Settings().SetTheme(brand{theme.DefaultTheme()}) + defer a.Quit() + + tapped := false + hint := textLine("", 12, false, colorNameMuted) + button := newIconButton(theme.MediaPlayIcon(), "jalankan shortcut", func() { tapped = true }) + w := a.NewWindow("tip") + w.SetContent(container.NewBorder(nil, hint, nil, nil, button)) + w.Resize(fyne.NewSize(300, 200)) + w.Show() + restore := pushHintTarget(hint) + defer restore() + + test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20)) + test.TapCanvas(w.Canvas(), fyne.NewPos(20, 20)) + if !tapped { + t.Error("klik pertama tidak sampai ke tombol") + } + if top := w.Canvas().Overlays().Top(); top != nil { + t.Error("tidak boleh ada overlay untuk keterangan tombol") + } +} + +func TestTipButtonWithoutTipDoesNothing(t *testing.T) { + a := test.NewApp() + a.Settings().SetTheme(brand{theme.DefaultTheme()}) + defer a.Quit() + + hint := textLine("", 12, false, colorNameMuted) + button := newButton("Tanpa tip", nil, "", widget.MediumImportance, func() {}) + w := a.NewWindow("tip") + w.SetContent(container.NewBorder(nil, hint, nil, nil, button)) + w.Resize(fyne.NewSize(300, 200)) + w.Show() + restore := pushHintTarget(hint) + defer restore() + + test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20)) + if hint.Text != "" { + t.Error("tombol tanpa tip tidak boleh menulis hint") + } +} diff --git a/internal/ui/update.go b/internal/ui/update.go index cd9a9b5..04596fd 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -110,18 +110,18 @@ func (ui *app) showUpdateDialog(rel update.Release) { buttons := container.NewHBox( logoBox(newLogo(), 20), spacer(), - widget.NewButton(T("update_later"), func() { d.Hide() }), - widget.NewButton(T("update_page"), func() { ui.openURL(rel.PageURL) }), + newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }), + newButton(T("update_page"), nil, T("tip_update_page"), widget.MediumImportance, func() { ui.openURL(rel.PageURL) }), ) if updates := ui.updaterBinary(); updates != "" { // AppImageUpdate yang memasang (dan memverifikasi) sendiri - buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() { + buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() { d.Hide() ui.runUpdater(updates, rel) })) } else { - buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() { + buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() { d.Hide() ui.downloadUpdate(rel) })) @@ -151,12 +151,11 @@ func (ui *app) downloadUpdate(rel update.Release) { return } - progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win) - progress.Show() + hide := ui.showLoading(T("check_updates"), T("update_downloading")) go func() { err := update.Download(rel, appImage) fyne.Do(func() { - progress.Hide() + hide() if err != nil { dialog.ShowError(err, ui.win) return @@ -174,12 +173,11 @@ func (ui *app) runUpdater(updater string, rel update.Release) { return } - progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win) - progress.Show() + hide := ui.showLoading(T("check_updates"), T("update_downloading")) go func() { err := exec.Command(updater, appImage).Run() fyne.Do(func() { - progress.Hide() + hide() if err != nil { dialog.ShowError(err, ui.win) return @@ -195,8 +193,8 @@ func (ui *app) offerRestart() { content := container.NewVBox( widget.NewLabel(T("update_installed")), container.NewHBox(spacer(), - widget.NewButton(T("update_later"), func() { d.Hide() }), - widget.NewButtonWithIcon(T("update_restart"), theme.ViewRefreshIcon(), ui.restart), + newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }), + newButton(T("update_restart"), theme.ViewRefreshIcon(), T("tip_restart"), widget.HighImportance, ui.restart), ), ) d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win)