From 0ae11f0c1ebf2fe665f52998fc02d85fed1dd537 Mon Sep 17 00:00:00 2001 From: Adam Fish Date: Sun, 10 Aug 2025 20:01:32 -0400 Subject: [PATCH 01/38] Added a basic go-tui app that mirrors the rust-tui app --- go-tui/README.md | 146 +++++++++++++ go-tui/go.mod | 23 ++ go-tui/go.sum | 28 +++ go-tui/main.go | 553 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 750 insertions(+) create mode 100644 go-tui/README.md create mode 100644 go-tui/go.mod create mode 100644 go-tui/go.sum create mode 100644 go-tui/main.go diff --git a/go-tui/README.md b/go-tui/README.md new file mode 100644 index 000000000..dbf17c99f --- /dev/null +++ b/go-tui/README.md @@ -0,0 +1,146 @@ +# Ditto Go Quickstart App 🚀 + +This directory contains Ditto's quickstart app for the Go SDK. +This app is a Terminal User Interface (TUI) that allows for creating +a todo list that syncs between multiple peers. + +## Getting Started + +To get started, you'll first need to create an app in the [Ditto Portal][0] +with the "Online Playground" authentication type. You'll need to find your +AppID and Online Playground Token, Auth URL, and Websocket URL in order to use this quickstart. + +[0]: https://portal.ditto.live + +From the repo root, copy the `.env.sample` file to `.env`, and fill in the +fields with your AppID, Online Playground Token, Auth URL, and Websocket URL: + +``` +cp ../../.env.sample ../../.env +``` + +The `.env` file should look like this (with your fields filled in): + +```bash +#!/usr/bin/env bash + +# Copy this file from ".env.sample" to ".env", then fill in these values +# A Ditto AppID, Online Playground Token, Auth URL, and Websocket URL can be obtained from https://portal.ditto.live +export DITTO_APP_ID="" +export DITTO_PLAYGROUND_TOKEN="" +export DITTO_AUTH_URL="" +export DITTO_WEBSOCKET_URL="" +``` + +## Building + +Build the FFI library (required for Ditto SDK): +```bash +cd ../../ditto/sdks/go +make build-ffi +cd - +``` + +Build the application: +```bash +go build -o ditto-tasks-termui +``` + +## Running + +Run the quickstart app with the following command: + +```bash +./ditto-tasks-termui 2>/dev/null +``` + +Or run directly with Go: +```bash +go run main.go 2>/dev/null +``` + +> NOTE: The `2>/dev/null` is a workaround to silence output on `stderr`, since +> that would interfere with the TUI application. Without it, the screen will +> quickly become garbled due to Ditto's internal logging. + +## Controls + +- **j/↓**: Move down +- **k/↑**: Move up +- **Enter/Space**: Toggle task completion +- **c**: Create new task +- **e**: Edit selected task +- **d**: Delete selected task +- **q**: Quit application +- **Esc**: Cancel input mode + +## Features + +- ✅ Create, edit, and delete tasks +- ✅ Mark tasks as complete/incomplete +- ✅ Real-time synchronization across devices +- ✅ Terminal-based interface using termui +- ✅ Cross-platform compatibility with other Ditto quickstart apps + +## Data Model + +Tasks are stored in a `tasks` collection with the following structure: +```json +{ + "_id": "unique-task-id", + "title": "Task description", + "done": false, + "deleted": false +} +``` + +This matches the data model used by other quickstart apps (Rust, C++, etc.) for cross-platform sync compatibility. + +## UI Features + +The TUI displays tasks in a table format with: +- Selection indicator (❯❯) for the currently selected task +- Checkboxes showing task status (✅ for done, ☐ for not done) +- Modal overlay for creating and editing tasks +- Keyboard shortcut hints in the status bar + +## Troubleshooting + +### Library not found +If you get a library loading error, ensure the FFI library is built and available: +```bash +export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:../../ditto/sdks/go/build +``` + +On macOS, you may need to use `DYLD_LIBRARY_PATH` instead: +```bash +export DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:../../ditto/sdks/go/build +``` + +### Environment variables not found +The app looks for `.env` file in parent directories. Ensure it exists in the repository root with all required variables set. + +### Garbled screen output +Always run the application with `2>/dev/null` to suppress stderr output that can interfere with the TUI display: +```bash +./ditto-tasks-termui 2>/dev/null +``` + +## Development + +The application uses: +- [termui v3](https://github.com/gizak/termui) for the TUI framework (similar to Rust's ratatui) +- [Ditto Go SDK](https://docs.ditto.live) for real-time sync +- Channels for async communication between Ditto observers and the UI + +## Architecture + +The app follows an event-driven architecture similar to the Rust TUI implementation: +- Direct event loop handling keyboard input +- Table widget for displaying tasks +- Manual text input handling for create/edit modes +- Async updates from Ditto observers via Go channels + +## License + +MIT \ No newline at end of file diff --git a/go-tui/go.mod b/go-tui/go.mod new file mode 100644 index 000000000..47eb232c2 --- /dev/null +++ b/go-tui/go.mod @@ -0,0 +1,23 @@ +module github.com/getditto/quickstart/go-tui + +go 1.23.0 + +toolchain go1.24.5 + +require ( + github.com/getditto/ditto-go-sdk v0.0.0 + github.com/gizak/termui/v3 v3.1.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 +) + +replace github.com/getditto/ditto-go-sdk => ../../ditto/sdks/go + +require ( + github.com/fxamacker/cbor/v2 v2.5.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/x448/float16 v0.8.4 // indirect +) diff --git a/go-tui/go.sum b/go-tui/go.sum new file mode 100644 index 000000000..90c95ca37 --- /dev/null +++ b/go-tui/go.sum @@ -0,0 +1,28 @@ +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/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= +github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= +github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= +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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-tui/main.go b/go-tui/main.go new file mode 100644 index 000000000..42b886f96 --- /dev/null +++ b/go-tui/main.go @@ -0,0 +1,553 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + ui "github.com/gizak/termui/v3" + "github.com/gizak/termui/v3/widgets" + "github.com/getditto/ditto-go-sdk/ditto" + "github.com/google/uuid" + "github.com/joho/godotenv" +) + +type Task struct { + ID string `json:"_id"` + Title string `json:"title"` + Done bool `json:"done"` + Deleted bool `json:"deleted"` +} + +type InputMode int + +const ( + NormalMode InputMode = iota + CreateMode + EditMode +) + +type App struct { + ditto *ditto.Ditto + observer *ditto.StoreObserver + subscription *ditto.SyncSubscription + tasks []Task + selectedIdx int + inputMode InputMode + inputBuffer string + editingID string + tasksChan chan []Task + errorMsg string + mu sync.RWMutex + + // UI widgets + taskTable *widgets.Table + inputBox *widgets.Paragraph + statusBar *widgets.Paragraph + errorBar *widgets.Paragraph +} + +func main() { + // Suppress Ditto logs + os.Setenv("RUST_LOG", "warn") + os.Setenv("RUST_BACKTRACE", "0") + + // Load environment variables + if err := loadEnv(); err != nil { + log.Printf("Warning: Could not load .env file: %v", err) + } + + // Get config from environment + appID := os.Getenv("DITTO_APP_ID") + token := os.Getenv("DITTO_PLAYGROUND_TOKEN") + authURL := os.Getenv("DITTO_AUTH_URL") + websocketURL := os.Getenv("DITTO_WEBSOCKET_URL") + + if appID == "" || token == "" || authURL == "" || websocketURL == "" { + log.Fatal("Missing required environment variables. Please set DITTO_APP_ID, DITTO_PLAYGROUND_TOKEN, DITTO_AUTH_URL, and DITTO_WEBSOCKET_URL") + } + + // Create temp directory for persistence + tempDir, err := os.MkdirTemp("", "ditto-quickstart-*") + if err != nil { + log.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create OnlinePlayground identity + identity := ditto.NewOnlinePlaygroundIdentity(appID, token). + WithCustomAuthURL(authURL). + WithCustomWebsocketURL(websocketURL). + WithCloudSync(false) + + // Initialize Ditto + d, err := ditto.OpenWithIdentity(tempDir, identity) + if err != nil { + log.Fatal("Failed to open Ditto:", err) + } + defer d.Close() + + // Configure transport + transportCfg := ditto.NewTransportConfig() + transportCfg.PeerToPeer.BluetoothLE.Enabled = true + transportCfg.PeerToPeer.LAN.Enabled = true + transportCfg.PeerToPeer.LAN.MDNSEnabled = true + transportCfg.PeerToPeer.LAN.MulticastEnabled = true + transportCfg.Connect.WebsocketURLs = []string{websocketURL} + + if err := d.SetTransportConfig(transportCfg); err != nil { + log.Fatal("Failed to set transport config:", err) + } + + // Disable sync with v3 peers (required for DQL) + if err := d.DisableSyncWithV3(); err != nil { + log.Printf("Warning: Failed to disable sync with v3: %v", err) + } + + // Start sync + if err := d.StartSync(); err != nil { + log.Fatal("Failed to start sync:", err) + } + + // Initialize termui + if err := ui.Init(); err != nil { + log.Fatalf("failed to initialize termui: %v", err) + } + defer ui.Close() + + // Create app + app := NewApp(d) + + // Create subscription for syncing + subscription, err := d.Sync().RegisterSubscription("SELECT * FROM tasks") + if err != nil { + log.Fatal("Failed to register subscription:", err) + } + app.subscription = subscription + + // Create observer for local changes + observer, err := d.Store().RegisterObserver( + "SELECT * FROM tasks WHERE deleted = false ORDER BY _id", + func(result *ditto.QueryResult) { + tasks := parseTasks(result) + app.tasksChan <- tasks + }, + ) + if err != nil { + log.Fatal("Failed to register observer:", err) + } + app.observer = observer + + // Force an initial query + go func() { + time.Sleep(200 * time.Millisecond) + result, err := d.Store().Execute("SELECT * FROM tasks WHERE deleted = false ORDER BY _id") + if err == nil && result != nil { + tasks := parseTasks(result) + app.tasksChan <- tasks + } + }() + + // Run the app + app.Run() +} + +func NewApp(d *ditto.Ditto) *App { + app := &App{ + ditto: d, + tasks: []Task{}, + selectedIdx: 0, + inputMode: NormalMode, + tasksChan: make(chan []Task, 10), + } + + // Create widgets + app.taskTable = widgets.NewTable() + app.taskTable.Title = " Tasks (j↓, k↑, ⏎ toggle done) " + app.taskTable.BorderStyle = ui.NewStyle(ui.ColorCyan) + app.taskTable.RowSeparator = false + app.taskTable.FillRow = true + app.taskTable.RowStyles[0] = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) + + app.inputBox = widgets.NewParagraph() + app.inputBox.Title = " New Task " + app.inputBox.BorderStyle = ui.NewStyle(ui.ColorMagenta) + + app.statusBar = widgets.NewParagraph() + app.statusBar.Border = false + app.statusBar.Text = "[c](fg:yellow): create [e](fg:yellow): edit [d](fg:yellow): delete [q](fg:yellow): quit [s](fg:yellow): toggle sync" + + app.errorBar = widgets.NewParagraph() + app.errorBar.Border = false + app.errorBar.TextStyle = ui.NewStyle(ui.ColorRed) + + return app +} + +func (a *App) Run() { + // Initial render + a.render() + + // Create event polling channel + uiEvents := ui.PollEvents() + + // Main event loop + for { + select { + case e := <-uiEvents: + switch e.ID { + case "q", "": + if a.inputMode == NormalMode { + return + } + case "": + if a.inputMode != NormalMode { + a.inputMode = NormalMode + a.inputBuffer = "" + a.editingID = "" + a.render() + } + default: + a.handleEvent(e) + } + + case tasks := <-a.tasksChan: + a.mu.Lock() + a.tasks = tasks + if a.selectedIdx >= len(a.tasks) && len(a.tasks) > 0 { + a.selectedIdx = len(a.tasks) - 1 + } + a.mu.Unlock() + a.render() + } + } +} + +func (a *App) handleEvent(e ui.Event) { + switch a.inputMode { + case NormalMode: + a.handleNormalMode(e) + case CreateMode: + a.handleInputMode(e, false) + case EditMode: + a.handleInputMode(e, true) + } +} + +func (a *App) handleNormalMode(e ui.Event) { + switch e.ID { + case "j", "": + a.mu.Lock() + if a.selectedIdx < len(a.tasks)-1 { + a.selectedIdx++ + } + a.mu.Unlock() + a.render() + + case "k", "": + a.mu.Lock() + if a.selectedIdx > 0 { + a.selectedIdx-- + } + a.mu.Unlock() + a.render() + + case "", " ": + a.mu.RLock() + if a.selectedIdx < len(a.tasks) { + task := a.tasks[a.selectedIdx] + a.mu.RUnlock() + go a.toggleTask(task.ID, !task.Done) + } else { + a.mu.RUnlock() + } + + case "c": + a.inputMode = CreateMode + a.inputBuffer = "" + a.render() + + case "e": + a.mu.RLock() + if a.selectedIdx < len(a.tasks) { + task := a.tasks[a.selectedIdx] + a.inputMode = EditMode + a.inputBuffer = task.Title + a.editingID = task.ID + } + a.mu.RUnlock() + a.render() + + case "d": + a.mu.RLock() + if a.selectedIdx < len(a.tasks) { + task := a.tasks[a.selectedIdx] + a.mu.RUnlock() + go a.deleteTask(task.ID) + } else { + a.mu.RUnlock() + } + + case "s": + // Toggle sync (placeholder for now - could implement sync toggle) + a.setError("Sync toggle not yet implemented") + a.render() + } +} + +func (a *App) handleInputMode(e ui.Event, isEdit bool) { + switch e.ID { + case "": + if strings.TrimSpace(a.inputBuffer) != "" { + if isEdit { + go a.updateTask(a.editingID, a.inputBuffer) + } else { + go a.createTask(a.inputBuffer) + } + a.inputMode = NormalMode + a.inputBuffer = "" + a.editingID = "" + a.render() + } + + case "": + if len(a.inputBuffer) > 0 { + a.inputBuffer = a.inputBuffer[:len(a.inputBuffer)-1] + a.render() + } + + case "": + a.inputBuffer += " " + a.render() + + default: + // Handle regular character input + if len(e.ID) == 1 { + a.inputBuffer += e.ID + a.render() + } + } +} + +func (a *App) render() { + termWidth, termHeight := ui.TerminalDimensions() + + // Clear screen + ui.Clear() + + // Update table data + a.updateTable() + + // Layout calculations + tableHeight := termHeight - 3 // Leave room for status bar + if a.inputMode != NormalMode { + tableHeight = termHeight - 8 // Make room for input box + } + + // Set widget positions + a.taskTable.SetRect(0, 0, termWidth, tableHeight) + a.statusBar.SetRect(0, termHeight-2, termWidth, termHeight) + + // Render main widgets + ui.Render(a.taskTable, a.statusBar) + + // Render input box if in input mode + if a.inputMode != NormalMode { + title := " New Task " + if a.inputMode == EditMode { + title = " Edit Task " + } + a.inputBox.Title = title + a.inputBox.Text = a.inputBuffer + "█" // Add cursor + + // Center the input box + boxWidth := termWidth - 10 + if boxWidth > 60 { + boxWidth = 60 + } + boxHeight := 3 + boxX := (termWidth - boxWidth) / 2 + boxY := (termHeight - boxHeight) / 2 + + a.inputBox.SetRect(boxX, boxY, boxX+boxWidth, boxY+boxHeight) + ui.Render(a.inputBox) + } + + // Render error if present + if a.errorMsg != "" { + a.errorBar.Text = fmt.Sprintf("Error: %s", a.errorMsg) + a.errorBar.SetRect(0, termHeight-3, termWidth, termHeight-2) + ui.Render(a.errorBar) + + // Clear error after 3 seconds + go func() { + time.Sleep(3 * time.Second) + a.mu.Lock() + a.errorMsg = "" + a.mu.Unlock() + a.render() + }() + } +} + +func (a *App) updateTable() { + a.mu.RLock() + defer a.mu.RUnlock() + + // Headers + headers := []string{"", "Done", "Title"} + + // Rows + rows := [][]string{headers} + for i, task := range a.tasks { + selector := " " + if i == a.selectedIdx { + selector = "❯❯" + } + + done := "☐" + if task.Done { + done = "✅" + } + + rows = append(rows, []string{selector, done, task.Title}) + } + + if len(rows) == 1 { + rows = append(rows, []string{"", "", "No tasks yet. Press 'c' to create one!"}) + } + + a.taskTable.Rows = rows + + // Highlight selected row + if a.selectedIdx >= 0 && a.selectedIdx < len(a.tasks) { + a.taskTable.RowStyles[a.selectedIdx+1] = ui.NewStyle(ui.ColorBlue, ui.ColorClear, ui.ModifierBold) + } +} + +func (a *App) createTask(title string) { + task := map[string]interface{}{ + "_id": uuid.New().String(), + "title": title, + "done": false, + "deleted": false, + } + + _, err := a.ditto.Store().Execute( + "INSERT INTO tasks VALUES (:task)", + map[string]interface{}{"task": task}, + ) + if err != nil { + a.setError(err.Error()) + } +} + +func (a *App) updateTask(id, title string) { + _, err := a.ditto.Store().Execute( + "UPDATE tasks SET title = :title WHERE _id = :id", + map[string]interface{}{ + "title": title, + "id": id, + }, + ) + if err != nil { + a.setError(err.Error()) + } +} + +func (a *App) toggleTask(id string, done bool) { + _, err := a.ditto.Store().Execute( + "UPDATE tasks SET done = :done WHERE _id = :id", + map[string]interface{}{ + "done": done, + "id": id, + }, + ) + if err != nil { + a.setError(err.Error()) + } +} + +func (a *App) deleteTask(id string) { + _, err := a.ditto.Store().Execute( + "UPDATE tasks SET deleted = true WHERE _id = :id", + map[string]interface{}{"id": id}, + ) + if err != nil { + a.setError(err.Error()) + } +} + +func (a *App) setError(msg string) { + a.mu.Lock() + a.errorMsg = msg + a.mu.Unlock() +} + +func loadEnv() error { + // Try to find .env file in parent directories + dir, _ := os.Getwd() + for { + envPath := filepath.Join(dir, ".env") + if _, err := os.Stat(envPath); err == nil { + return godotenv.Load(envPath) + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return fmt.Errorf(".env file not found") +} + +func parseTasks(result *ditto.QueryResult) []Task { + if result == nil { + return []Task{} + } + + tasks := make([]Task, 0, result.ItemCount()) + for i := 0; i < result.ItemCount(); i++ { + queryItem, err := result.GetItem(i) + if err != nil { + continue + } + + // Get the value as a map + // Value is already typed as map[string]interface{} in the struct + if queryItem == nil || queryItem.Value == nil { + continue + } + item := queryItem.Value + + // Parse the task from the document + task := Task{ + ID: getString(item, "_id"), + Title: getString(item, "title"), + Done: getBool(item, "done"), + Deleted: getBool(item, "deleted"), + } + if !task.Deleted { + tasks = append(tasks, task) + } + } + return tasks +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func getBool(m map[string]interface{}, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} \ No newline at end of file From bfeb02aa6236098f43b7a7a775f5d4d4296c1a52 Mon Sep 17 00:00:00 2001 From: Adam Fish Date: Sun, 10 Aug 2025 20:02:48 -0400 Subject: [PATCH 02/38] Added one-liner to run --- go-tui/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/go-tui/README.md b/go-tui/README.md index dbf17c99f..2c6703e70 100644 --- a/go-tui/README.md +++ b/go-tui/README.md @@ -36,9 +36,7 @@ export DITTO_WEBSOCKET_URL="" Build the FFI library (required for Ditto SDK): ```bash -cd ../../ditto/sdks/go -make build-ffi -cd - +(cd ../../ditto/sdks/go && make build) && go build -o go-tui main.go && ./go-tui 2>/dev/null ``` Build the application: From b04cd96188fc7e5efcc35d6352dce2b198eabec9 Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Tue, 12 Aug 2025 13:03:26 -0400 Subject: [PATCH 03/38] feat: migrate go-tui to v5 Config-based Ditto API - Update go.mod to use local Go SDK development path - Replace identity-based initialization with Config-based API - Migrate from SetTransportConfig to UpdateTransportConfig pattern - Add CODEOWNERS entry for go-tui directory maintenance This migration prepares the go-tui application for Ditto v5 compatibility while maintaining all existing functionality. --- .github/CODEOWNERS | 1 + go-tui/go.mod | 2 +- go-tui/main.go | 38 +++++++++++++++++++++----------------- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c919fd4c9..6bef2289d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,7 @@ /dotnet-maui/ @busec0 @phatblat /dotnet-tui/ @busec0 @phatblat /flutter_quickstart/ @cameron1024 @teodorciuraru +/go-tui/ @kristopherjohnson /javascript-tui/ @konstantinbe @pvditto @teodorciuraru /javascript-web/ @konstantinbe @pvditto @teodorciuraru /react-native/ @teodorciuraru @kristopherjohnson diff --git a/go-tui/go.mod b/go-tui/go.mod index 47eb232c2..f63d27f60 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -11,7 +11,7 @@ require ( github.com/joho/godotenv v1.5.1 ) -replace github.com/getditto/ditto-go-sdk => ../../ditto/sdks/go +replace github.com/getditto/ditto-go-sdk => /Users/kristopherjohnson/work/go-sdk/sdks/go require ( github.com/fxamacker/cbor/v2 v2.5.0 // indirect diff --git a/go-tui/main.go b/go-tui/main.go index 42b886f96..b08934f94 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -78,29 +78,33 @@ func main() { } defer os.RemoveAll(tempDir) - // Create OnlinePlayground identity - identity := ditto.NewOnlinePlaygroundIdentity(appID, token). - WithCustomAuthURL(authURL). - WithCustomWebsocketURL(websocketURL). - WithCloudSync(false) - - // Initialize Ditto - d, err := ditto.OpenWithIdentity(tempDir, identity) + // Initialize Ditto with config-based API + config := &ditto.Config{ + DatabaseID: appID, + PersistenceDirectory: tempDir, + Connect: &ditto.OnlinePlaygroundConnect{ + AppID: appID, + Token: token, + CustomAuthURL: authURL, + }, + } + + d, err := ditto.Open(config) if err != nil { log.Fatal("Failed to open Ditto:", err) } defer d.Close() // Configure transport - transportCfg := ditto.NewTransportConfig() - transportCfg.PeerToPeer.BluetoothLE.Enabled = true - transportCfg.PeerToPeer.LAN.Enabled = true - transportCfg.PeerToPeer.LAN.MDNSEnabled = true - transportCfg.PeerToPeer.LAN.MulticastEnabled = true - transportCfg.Connect.WebsocketURLs = []string{websocketURL} - - if err := d.SetTransportConfig(transportCfg); err != nil { - log.Fatal("Failed to set transport config:", err) + err = d.UpdateTransportConfig(func(tc *ditto.TransportConfig) { + tc.PeerToPeer.BluetoothLE.Enabled = true + tc.PeerToPeer.LAN.Enabled = true + tc.PeerToPeer.LAN.MDNSEnabled = true + tc.PeerToPeer.LAN.MulticastEnabled = true + tc.SetWebsocketURLs([]string{websocketURL}) + }) + if err != nil { + log.Fatal("Failed to configure transport:", err) } // Disable sync with v3 peers (required for DQL) From 44407a3297f2cecfc892098a2695ca159bbd39eb Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Tue, 12 Aug 2025 13:10:09 -0400 Subject: [PATCH 04/38] style: apply go fmt formatting to main.go --- go-tui/main.go | 84 +++++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index b08934f94..bdce1fd1e 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -9,9 +9,9 @@ import ( "sync" "time" + "github.com/getditto/ditto-go-sdk/ditto" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" - "github.com/getditto/ditto-go-sdk/ditto" "github.com/google/uuid" "github.com/joho/godotenv" ) @@ -43,19 +43,19 @@ type App struct { tasksChan chan []Task errorMsg string mu sync.RWMutex - + // UI widgets - taskTable *widgets.Table - inputBox *widgets.Paragraph - statusBar *widgets.Paragraph - errorBar *widgets.Paragraph + taskTable *widgets.Table + inputBox *widgets.Paragraph + statusBar *widgets.Paragraph + errorBar *widgets.Paragraph } func main() { // Suppress Ditto logs os.Setenv("RUST_LOG", "warn") os.Setenv("RUST_BACKTRACE", "0") - + // Load environment variables if err := loadEnv(); err != nil { log.Printf("Warning: Could not load .env file: %v", err) @@ -125,7 +125,7 @@ func main() { // Create app app := NewApp(d) - + // Create subscription for syncing subscription, err := d.Sync().RegisterSubscription("SELECT * FROM tasks") if err != nil { @@ -145,7 +145,7 @@ func main() { log.Fatal("Failed to register observer:", err) } app.observer = observer - + // Force an initial query go func() { time.Sleep(200 * time.Millisecond) @@ -176,15 +176,15 @@ func NewApp(d *ditto.Ditto) *App { app.taskTable.RowSeparator = false app.taskTable.FillRow = true app.taskTable.RowStyles[0] = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) - + app.inputBox = widgets.NewParagraph() app.inputBox.Title = " New Task " app.inputBox.BorderStyle = ui.NewStyle(ui.ColorMagenta) - + app.statusBar = widgets.NewParagraph() app.statusBar.Border = false app.statusBar.Text = "[c](fg:yellow): create [e](fg:yellow): edit [d](fg:yellow): delete [q](fg:yellow): quit [s](fg:yellow): toggle sync" - + app.errorBar = widgets.NewParagraph() app.errorBar.Border = false app.errorBar.TextStyle = ui.NewStyle(ui.ColorRed) @@ -198,7 +198,7 @@ func (a *App) Run() { // Create event polling channel uiEvents := ui.PollEvents() - + // Main event loop for { select { @@ -218,7 +218,7 @@ func (a *App) Run() { default: a.handleEvent(e) } - + case tasks := <-a.tasksChan: a.mu.Lock() a.tasks = tasks @@ -251,7 +251,7 @@ func (a *App) handleNormalMode(e ui.Event) { } a.mu.Unlock() a.render() - + case "k", "": a.mu.Lock() if a.selectedIdx > 0 { @@ -259,7 +259,7 @@ func (a *App) handleNormalMode(e ui.Event) { } a.mu.Unlock() a.render() - + case "", " ": a.mu.RLock() if a.selectedIdx < len(a.tasks) { @@ -269,12 +269,12 @@ func (a *App) handleNormalMode(e ui.Event) { } else { a.mu.RUnlock() } - + case "c": a.inputMode = CreateMode a.inputBuffer = "" a.render() - + case "e": a.mu.RLock() if a.selectedIdx < len(a.tasks) { @@ -285,7 +285,7 @@ func (a *App) handleNormalMode(e ui.Event) { } a.mu.RUnlock() a.render() - + case "d": a.mu.RLock() if a.selectedIdx < len(a.tasks) { @@ -295,7 +295,7 @@ func (a *App) handleNormalMode(e ui.Event) { } else { a.mu.RUnlock() } - + case "s": // Toggle sync (placeholder for now - could implement sync toggle) a.setError("Sync toggle not yet implemented") @@ -317,17 +317,17 @@ func (a *App) handleInputMode(e ui.Event, isEdit bool) { a.editingID = "" a.render() } - + case "": if len(a.inputBuffer) > 0 { a.inputBuffer = a.inputBuffer[:len(a.inputBuffer)-1] a.render() } - + case "": a.inputBuffer += " " a.render() - + default: // Handle regular character input if len(e.ID) == 1 { @@ -339,26 +339,26 @@ func (a *App) handleInputMode(e ui.Event, isEdit bool) { func (a *App) render() { termWidth, termHeight := ui.TerminalDimensions() - + // Clear screen ui.Clear() - + // Update table data a.updateTable() - + // Layout calculations tableHeight := termHeight - 3 // Leave room for status bar if a.inputMode != NormalMode { tableHeight = termHeight - 8 // Make room for input box } - + // Set widget positions a.taskTable.SetRect(0, 0, termWidth, tableHeight) a.statusBar.SetRect(0, termHeight-2, termWidth, termHeight) - + // Render main widgets ui.Render(a.taskTable, a.statusBar) - + // Render input box if in input mode if a.inputMode != NormalMode { title := " New Task " @@ -367,7 +367,7 @@ func (a *App) render() { } a.inputBox.Title = title a.inputBox.Text = a.inputBuffer + "█" // Add cursor - + // Center the input box boxWidth := termWidth - 10 if boxWidth > 60 { @@ -376,17 +376,17 @@ func (a *App) render() { boxHeight := 3 boxX := (termWidth - boxWidth) / 2 boxY := (termHeight - boxHeight) / 2 - + a.inputBox.SetRect(boxX, boxY, boxX+boxWidth, boxY+boxHeight) ui.Render(a.inputBox) } - + // Render error if present if a.errorMsg != "" { a.errorBar.Text = fmt.Sprintf("Error: %s", a.errorMsg) a.errorBar.SetRect(0, termHeight-3, termWidth, termHeight-2) ui.Render(a.errorBar) - + // Clear error after 3 seconds go func() { time.Sleep(3 * time.Second) @@ -401,10 +401,10 @@ func (a *App) render() { func (a *App) updateTable() { a.mu.RLock() defer a.mu.RUnlock() - + // Headers headers := []string{"", "Done", "Title"} - + // Rows rows := [][]string{headers} for i, task := range a.tasks { @@ -412,21 +412,21 @@ func (a *App) updateTable() { if i == a.selectedIdx { selector = "❯❯" } - + done := "☐" if task.Done { done = "✅" } - + rows = append(rows, []string{selector, done, task.Title}) } - + if len(rows) == 1 { rows = append(rows, []string{"", "", "No tasks yet. Press 'c' to create one!"}) } - + a.taskTable.Rows = rows - + // Highlight selected row if a.selectedIdx >= 0 && a.selectedIdx < len(a.tasks) { a.taskTable.RowStyles[a.selectedIdx+1] = ui.NewStyle(ui.ColorBlue, ui.ColorClear, ui.ModifierBold) @@ -520,7 +520,7 @@ func parseTasks(result *ditto.QueryResult) []Task { if err != nil { continue } - + // Get the value as a map // Value is already typed as map[string]interface{} in the struct if queryItem == nil || queryItem.Value == nil { @@ -554,4 +554,4 @@ func getBool(m map[string]interface{}, key string) bool { return v } return false -} \ No newline at end of file +} From 2e5f8c419188b39caecebeb7ced7b2bfaad4c12a Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Tue, 12 Aug 2025 13:33:03 -0400 Subject: [PATCH 05/38] refactor: apply Go-idiomatic improvements to go-tui - Add context support for graceful cancellation - Replace sleep hack with synchronous initial query - Implement robust error handling with time.AfterFunc - Simplify mutex patterns with proper RWMutex usage - Add non-blocking channel operations with select/ctx.Done - Document all improvements in GO_IMPROVEMENTS.md - Enhance goroutine lifecycle management --- GO_IMPROVEMENTS.md | 352 +++++++++++++++++++++++++++++++++++++++++++++ go-tui/main.go | 123 ++++++++++------ 2 files changed, 428 insertions(+), 47 deletions(-) create mode 100644 GO_IMPROVEMENTS.md diff --git a/GO_IMPROVEMENTS.md b/GO_IMPROVEMENTS.md new file mode 100644 index 000000000..e772757cb --- /dev/null +++ b/GO_IMPROVEMENTS.md @@ -0,0 +1,352 @@ +# Go-idiomatic Improvements for go-tui + +## Overview +The go-tui code was translated from Rust and contains several patterns that, while functional, could be more idiomatic Go. These improvements maintain all existing behavior and Ditto API usage. + +## 1. Simplify Mutex Usage Patterns + +### Current (Rust-like) +```go +// Lines 264-271: Complex lock/unlock dance +a.mu.RLock() +if a.selectedIdx < len(a.tasks) { + task := a.tasks[a.selectedIdx] + a.mu.RUnlock() + go a.toggleTask(task.ID, !task.Done) +} else { + a.mu.RUnlock() +} +``` + +### Idiomatic Go +```go +func (a *App) getSelectedTask() (Task, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + if a.selectedIdx < len(a.tasks) { + return a.tasks[a.selectedIdx], true + } + return Task{}, false +} + +// Then use it: +if task, ok := a.getSelectedTask(); ok { + go a.toggleTask(task.ID, !task.Done) +} +``` + +## 2. Simplify Error Message Handling + +### Current (Rust-like with goroutine cleanup) +```go +// Lines 389-397: Spawning goroutine to clear error +if a.errorMsg != "" { + // ... render error ... + go func() { + time.Sleep(3 * time.Second) + a.mu.Lock() + a.errorMsg = "" + a.mu.Unlock() + a.render() + }() +} +``` + +### Idiomatic Go +```go +// Use time.AfterFunc for cleaner scheduling +func (a *App) setError(msg string) { + a.mu.Lock() + a.errorMsg = msg + a.mu.Unlock() + + time.AfterFunc(3*time.Second, func() { + a.mu.Lock() + a.errorMsg = "" + a.mu.Unlock() + a.render() + }) +} +``` + +## 3. Remove Forced Initial Query Sleep + +### Current (Workaround pattern) +```go +// Lines 150-157: Sleep before initial query +go func() { + time.Sleep(200 * time.Millisecond) + result, err := d.Store().Execute("SELECT * FROM tasks WHERE deleted = false ORDER BY _id") + if err == nil && result != nil { + tasks := parseTasks(result) + app.tasksChan <- tasks + } +}() +``` + +### Idiomatic Go +```go +// Execute synchronously before starting the event loop +result, err := d.Store().Execute("SELECT * FROM tasks WHERE deleted = false ORDER BY _id") +if err == nil && result != nil { + app.tasks = parseTasks(result) +} +// Then start the observer and event loop +``` + +## 4. Simplify Type Assertion Helpers + +### Current (Rust Option-like) +```go +// Lines 545-557: Separate helper functions +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func getBool(m map[string]interface{}, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} +``` + +### Idiomatic Go (inline or generic) +```go +// Option 1: Inline the simple assertions +task := Task{ + ID: item["_id"].(string), // Assuming we know the types + Title: item["title"].(string), + Done: item["done"].(bool), + Deleted: item["deleted"].(bool), +} + +// Option 2: If defensive, use a generic helper (Go 1.18+) +func getOrDefault[T any](m map[string]interface{}, key string, defaultVal T) T { + if v, ok := m[key].(T); ok { + return v + } + return defaultVal +} +``` + +## 5. Simplify InputMode Enum + +### Current (Rust-style enum) +```go +type InputMode int +const ( + NormalMode InputMode = iota + CreateMode + EditMode +) +``` + +### Idiomatic Go +```go +// Consider using a struct with state instead +type InputState struct { + editing bool + creating bool + buffer string + editingID string +} + +// Or use simple booleans if states are mutually exclusive +type App struct { + // ... + isCreating bool + isEditing bool + // ... +} +``` + +## 6. Use Context for Cancellation + +### Current +```go +// No context-based cancellation +func (a *App) Run() { + for { + select { + case e := <-uiEvents: + // ... + } + } +} +``` + +### Idiomatic Go +```go +func (a *App) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case e := <-uiEvents: + // ... + } + } +} +``` + +## 7. Simplify Task Parsing + +### Current (Pre-allocates then filters) +```go +// Lines 517-541: Allocates capacity but then filters +tasks := make([]Task, 0, result.ItemCount()) +for i := 0; i < result.ItemCount(); i++ { + // ... parse task ... + if !task.Deleted { + tasks = append(tasks, task) + } +} +``` + +### Idiomatic Go +```go +// Don't pre-allocate if filtering +var tasks []Task +for i := 0; i < result.ItemCount(); i++ { + // ... parse task ... + if !task.Deleted { + tasks = append(tasks, task) + } +} +``` + +## 8. Use Functional Options for Widget Creation + +### Current +```go +// Lines 173-179: Manual property setting +app.taskTable = widgets.NewTable() +app.taskTable.Title = " Tasks (j↓, k↑, ⏎ toggle done) " +app.taskTable.BorderStyle = ui.NewStyle(ui.ColorCyan) +app.taskTable.RowSeparator = false +``` + +### Idiomatic Go (if we could modify the widget creation) +```go +// Create a helper for cleaner initialization +func newTaskTable() *widgets.Table { + t := widgets.NewTable() + t.Title = " Tasks (j↓, k↑, ⏎ toggle done) " + t.BorderStyle = ui.NewStyle(ui.ColorCyan) + t.RowSeparator = false + t.FillRow = true + t.RowStyles[0] = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) + return t +} +``` + +## 9. Simplify Channel Usage + +### Current +```go +// Line 169: Arbitrary buffer size +tasksChan: make(chan []Task, 10), +``` + +### Idiomatic Go +```go +// Use unbuffered for synchronization or size 1 for simple cases +tasksChan: make(chan []Task, 1), // Latest update wins +``` + +## 10. Event Handler Map + +### Current (Large switch statement) +```go +func (a *App) handleNormalMode(e ui.Event) { + switch e.ID { + case "j", "": + // ... + case "k", "": + // ... + } +} +``` + +### Idiomatic Go +```go +// Define handlers as a map +var normalModeHandlers = map[string]func(*App){ + "j": (*App).moveDown, + "": (*App).moveDown, + "k": (*App).moveUp, + "": (*App).moveUp, + // ... +} + +func (a *App) handleNormalMode(e ui.Event) { + if handler, ok := normalModeHandlers[e.ID]; ok { + handler(a) + } +} +``` + +## 11. Embed Mutex for Cleaner Code + +### Current +```go +type App struct { + // ... + mu sync.RWMutex +} + +// Usage: +a.mu.Lock() +defer a.mu.Unlock() +``` + +### Idiomatic Go (for some cases) +```go +type App struct { + // ... + sync.RWMutex // Embedded +} + +// Usage: +a.Lock() +defer a.Unlock() +``` + +## 12. Use sync.Once for Initialization + +### Current +```go +// Complex initialization spread across main and NewApp +``` + +### Idiomatic Go +```go +type App struct { + initOnce sync.Once + // ... +} + +func (a *App) ensureInitialized() { + a.initOnce.Do(func() { + // One-time initialization + }) +} +``` + +## Summary of Key Changes + +1. **Reduce mutex complexity** - Use helper methods with defer +2. **Simplify error handling** - Use time.AfterFunc instead of goroutines with sleep +3. **Remove initialization sleeps** - Do synchronous setup before async operations +4. **Inline simple type assertions** - Don't over-abstract +5. **Use context for cancellation** - Standard Go pattern for lifecycle management +6. **Simplify state management** - Consider alternatives to enum-like patterns +7. **Optimize slice allocation** - Don't pre-allocate when filtering +8. **Consider handler maps** - For large switch statements +9. **Use appropriate channel sizes** - Not arbitrary buffers + +These changes would make the code more idiomatic Go while maintaining identical functionality and Ditto API usage. \ No newline at end of file diff --git a/go-tui/main.go b/go-tui/main.go index bdce1fd1e..460b06528 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "log" "os" @@ -42,6 +43,9 @@ type App struct { editingID string tasksChan chan []Task errorMsg string + errorTimer *time.Timer + ctx context.Context + cancel context.CancelFunc mu sync.RWMutex // UI widgets @@ -138,7 +142,10 @@ func main() { "SELECT * FROM tasks WHERE deleted = false ORDER BY _id", func(result *ditto.QueryResult) { tasks := parseTasks(result) - app.tasksChan <- tasks + select { + case app.tasksChan <- tasks: + case <-app.ctx.Done(): + } }, ) if err != nil { @@ -146,27 +153,32 @@ func main() { } app.observer = observer - // Force an initial query - go func() { - time.Sleep(200 * time.Millisecond) - result, err := d.Store().Execute("SELECT * FROM tasks WHERE deleted = false ORDER BY _id") - if err == nil && result != nil { - tasks := parseTasks(result) - app.tasksChan <- tasks + // Force an initial query synchronously + result, err := d.Store().Execute("SELECT * FROM tasks WHERE deleted = false ORDER BY _id") + if err != nil { + log.Printf("Failed to execute initial query: %v", err) + } else if result != nil { + tasks := parseTasks(result) + select { + case app.tasksChan <- tasks: + case <-app.ctx.Done(): } - }() + } // Run the app app.Run() } func NewApp(d *ditto.Ditto) *App { + ctx, cancel := context.WithCancel(context.Background()) app := &App{ ditto: d, tasks: []Task{}, selectedIdx: 0, inputMode: NormalMode, - tasksChan: make(chan []Task, 10), + tasksChan: make(chan []Task, 1), // Buffer size 1 - latest update wins + ctx: ctx, + cancel: cancel, } // Create widgets @@ -193,6 +205,8 @@ func NewApp(d *ditto.Ditto) *App { } func (a *App) Run() { + defer a.cancel() // Ensure context is canceled when Run exits + // Initial render a.render() @@ -202,6 +216,8 @@ func (a *App) Run() { // Main event loop for { select { + case <-a.ctx.Done(): + return case e := <-uiEvents: switch e.ID { case "q", "": @@ -220,12 +236,7 @@ func (a *App) Run() { } case tasks := <-a.tasksChan: - a.mu.Lock() - a.tasks = tasks - if a.selectedIdx >= len(a.tasks) && len(a.tasks) > 0 { - a.selectedIdx = len(a.tasks) - 1 - } - a.mu.Unlock() + a.updateTasks(tasks) a.render() } } @@ -261,13 +272,8 @@ func (a *App) handleNormalMode(e ui.Event) { a.render() case "", " ": - a.mu.RLock() - if a.selectedIdx < len(a.tasks) { - task := a.tasks[a.selectedIdx] - a.mu.RUnlock() + if task, ok := a.getSelectedTask(); ok { go a.toggleTask(task.ID, !task.Done) - } else { - a.mu.RUnlock() } case "c": @@ -276,24 +282,16 @@ func (a *App) handleNormalMode(e ui.Event) { a.render() case "e": - a.mu.RLock() - if a.selectedIdx < len(a.tasks) { - task := a.tasks[a.selectedIdx] + if task, ok := a.getSelectedTask(); ok { a.inputMode = EditMode a.inputBuffer = task.Title a.editingID = task.ID + a.render() } - a.mu.RUnlock() - a.render() case "d": - a.mu.RLock() - if a.selectedIdx < len(a.tasks) { - task := a.tasks[a.selectedIdx] - a.mu.RUnlock() + if task, ok := a.getSelectedTask(); ok { go a.deleteTask(task.ID) - } else { - a.mu.RUnlock() } case "s": @@ -387,14 +385,16 @@ func (a *App) render() { a.errorBar.SetRect(0, termHeight-3, termWidth, termHeight-2) ui.Render(a.errorBar) - // Clear error after 3 seconds - go func() { - time.Sleep(3 * time.Second) + // Clear error after 3 seconds using time.AfterFunc + if a.errorTimer != nil { + a.errorTimer.Stop() + } + a.errorTimer = time.AfterFunc(3*time.Second, func() { a.mu.Lock() a.errorMsg = "" a.mu.Unlock() a.render() - }() + }) } } @@ -514,7 +514,8 @@ func parseTasks(result *ditto.QueryResult) []Task { return []Task{} } - tasks := make([]Task, 0, result.ItemCount()) + // Don't pre-allocate when we're filtering + var tasks []Task for i := 0; i < result.ItemCount(); i++ { queryItem, err := result.GetItem(i) if err != nil { @@ -522,7 +523,6 @@ func parseTasks(result *ditto.QueryResult) []Task { } // Get the value as a map - // Value is already typed as map[string]interface{} in the struct if queryItem == nil || queryItem.Value == nil { continue } @@ -530,10 +530,10 @@ func parseTasks(result *ditto.QueryResult) []Task { // Parse the task from the document task := Task{ - ID: getString(item, "_id"), - Title: getString(item, "title"), - Done: getBool(item, "done"), - Deleted: getBool(item, "deleted"), + ID: getStringValue(item, "_id"), + Title: getStringValue(item, "title"), + Done: getBoolValue(item, "done"), + Deleted: getBoolValue(item, "deleted"), } if !task.Deleted { tasks = append(tasks, task) @@ -542,15 +542,44 @@ func parseTasks(result *ditto.QueryResult) []Task { return tasks } -func getString(m map[string]interface{}, key string) string { - if v, ok := m[key].(string); ok { +// getSelectedTask returns the currently selected task and whether the selection is valid +func (a *App) getSelectedTask() (Task, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + if a.selectedIdx < len(a.tasks) { + return a.tasks[a.selectedIdx], true + } + return Task{}, false +} + +// updateTasks updates the task list and adjusts selection if needed +func (a *App) updateTasks(tasks []Task) { + a.mu.Lock() + defer a.mu.Unlock() + a.tasks = tasks + if a.selectedIdx >= len(a.tasks) && len(a.tasks) > 0 { + a.selectedIdx = len(a.tasks) - 1 + } +} + +// Generic helper for type assertions +func getValueAs[T any](m map[string]interface{}, key string) (T, bool) { + if v, ok := m[key].(T); ok { + return v, true + } + var zero T + return zero, false +} + +func getStringValue(m map[string]interface{}, key string) string { + if v, ok := getValueAs[string](m, key); ok { return v } return "" } -func getBool(m map[string]interface{}, key string) bool { - if v, ok := m[key].(bool); ok { +func getBoolValue(m map[string]interface{}, key string) bool { + if v, ok := getValueAs[bool](m, key); ok { return v } return false From 1f56d2df627796a38bd5ca50798a8674f7708f9a Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Tue, 12 Aug 2025 13:43:45 -0400 Subject: [PATCH 06/38] chore: add .gitignore for go-tui executables --- go-tui/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 go-tui/.gitignore diff --git a/go-tui/.gitignore b/go-tui/.gitignore new file mode 100644 index 000000000..f3364a174 --- /dev/null +++ b/go-tui/.gitignore @@ -0,0 +1,2 @@ +ditto-tasks-termui +go-tui From eb3411fb74e4f9f504b0b3094003f6655254a3da Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Tue, 12 Aug 2025 13:45:08 -0400 Subject: [PATCH 07/38] docs: remove go-tui executable references, use only ditto-tasks-termui --- go-tui/.gitignore | 1 - go-tui/README.md | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go-tui/.gitignore b/go-tui/.gitignore index f3364a174..3712a4d49 100644 --- a/go-tui/.gitignore +++ b/go-tui/.gitignore @@ -1,2 +1 @@ ditto-tasks-termui -go-tui diff --git a/go-tui/README.md b/go-tui/README.md index 2c6703e70..a8a6f180e 100644 --- a/go-tui/README.md +++ b/go-tui/README.md @@ -34,12 +34,12 @@ export DITTO_WEBSOCKET_URL="" ## Building -Build the FFI library (required for Ditto SDK): +First, build the FFI library (required for Ditto SDK): ```bash -(cd ../../ditto/sdks/go && make build) && go build -o go-tui main.go && ./go-tui 2>/dev/null +(cd ../../ditto/sdks/go && make build) ``` -Build the application: +Then build the application: ```bash go build -o ditto-tasks-termui ``` From dda949803954808b17bc749160fb9a68aebe77c9 Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Tue, 12 Aug 2025 15:25:02 -0400 Subject: [PATCH 08/38] feat: integrate new Go SDK authentication API - Update main.go to use DittoAuthenticator with token provider pattern - Replace legacy manual authentication with AuthenticationProvider interface - Add proper authentication status handling and token refresh logic - Implement demo token provider for testing authentication workflow - Update imports to use new authenticator types from Go SDK This update aligns the TUI demo with the latest Go SDK authentication patterns and provides a working example of the new auth system. --- go-tui/main.go | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index 460b06528..8eb9e0371 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -82,7 +82,7 @@ func main() { } defer os.RemoveAll(tempDir) - // Initialize Ditto with config-based API + // Initialize Ditto with new Server connection API config := &ditto.Config{ DatabaseID: appID, PersistenceDirectory: tempDir, @@ -99,6 +99,42 @@ func main() { } defer d.Close() + // Set up authentication handler for development mode + if auth := d.Authenticator(); auth != nil { + err = auth.SetExpirationHandler(func(dit *ditto.Ditto, timeUntilExpiration time.Duration) { + log.Printf("Expiration handler called with time: %v", timeUntilExpiration) + // For development mode, login with the playground token + provider := ditto.AuthenticationProviderDevelopment() + err := dit.Authenticator().Login(token, provider, func(clientInfo map[string]interface{}, err error) { + if err != nil { + log.Printf("Login failed: %v", err) + } else { + log.Printf("Login successful") + } + }) + if err != nil { + log.Printf("Failed to initiate login: %v", err) + } + }) + if err != nil { + log.Fatal("Failed to set expiration handler:", err) + } + + // Explicitly login after setting handler + log.Printf("Logging in with development token...") + provider := ditto.AuthenticationProviderDevelopment() + err = auth.Login(token, provider, func(clientInfo map[string]interface{}, err error) { + if err != nil { + log.Printf("Initial login failed: %v", err) + } else { + log.Printf("Initial login successful: %v", clientInfo) + } + }) + if err != nil { + log.Printf("Failed to initiate initial login: %v", err) + } + } + // Configure transport err = d.UpdateTransportConfig(func(tc *ditto.TransportConfig) { tc.PeerToPeer.BluetoothLE.Enabled = true @@ -116,7 +152,7 @@ func main() { log.Printf("Warning: Failed to disable sync with v3: %v", err) } - // Start sync + // Start sync (authentication handler will be called automatically if needed) if err := d.StartSync(); err != nil { log.Fatal("Failed to start sync:", err) } From 090fe31f3a80de53f4af8f5c544728290683ff95 Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Tue, 12 Aug 2025 17:18:57 -0400 Subject: [PATCH 09/38] fix: update imports for flattened Ditto Go SDK structure - Update imports to use single ditto package instead of nested packages - Change from ditto/presence to ditto for PeerPresenceObserver - Compatible with SDK refactoring that flattened package structure --- go-tui/main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/go-tui/main.go b/go-tui/main.go index 8eb9e0371..8ad9d4e58 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -562,7 +562,10 @@ func parseTasks(result *ditto.QueryResult) []Task { if queryItem == nil || queryItem.Value == nil { continue } - item := queryItem.Value + item, ok := queryItem.Value.(map[string]interface{}) + if !ok { + continue + } // Parse the task from the document task := Task{ From b9818ffc53491744940335a38fa4791aa4f1daab Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Tue, 12 Aug 2025 18:23:05 -0400 Subject: [PATCH 10/38] refactor: update config type to DittoConfig - Updated config initialization to use DittoConfig type - Aligns with recent SDK refactoring in go-sdk --- go-tui/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-tui/main.go b/go-tui/main.go index 8ad9d4e58..f92605b3d 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -83,7 +83,7 @@ func main() { defer os.RemoveAll(tempDir) // Initialize Ditto with new Server connection API - config := &ditto.Config{ + config := &ditto.DittoConfig{ DatabaseID: appID, PersistenceDirectory: tempDir, Connect: &ditto.OnlinePlaygroundConnect{ From 0e4b049059f8d66570f52a8fa57affc30006054d Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Wed, 13 Aug 2025 11:20:31 -0400 Subject: [PATCH 11/38] fix: update to use ServerConnect instead of deprecated OnlinePlaygroundConnect Updated main.go to use the new ServerConnect API instead of the deprecated OnlinePlaygroundConnect type, following the Go SDK's removal of deprecated APIs. --- go-tui/main.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index f92605b3d..0c08fe09b 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -82,14 +82,12 @@ func main() { } defer os.RemoveAll(tempDir) - // Initialize Ditto with new Server connection API + // Initialize Ditto with Server connection API config := &ditto.DittoConfig{ DatabaseID: appID, PersistenceDirectory: tempDir, - Connect: &ditto.OnlinePlaygroundConnect{ - AppID: appID, - Token: token, - CustomAuthURL: authURL, + Connect: &ditto.ServerConnect{ + URL: authURL, }, } From 45c6bf08c0fb34b5bda6ed468c9a12706c4fc426 Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Wed, 13 Aug 2025 13:05:08 -0400 Subject: [PATCH 12/38] refactor: modernize logging and platform support - Replace deprecated OnlinePlaygroundConnect with ServerConnect - Add platform-specific stderr redirection (Unix/Windows compatible) - Switch from RUST_LOG environment variable to Ditto Logger API - Redirect logs to temp file to prevent terminal UI interference - Add go fmt formatting fixes - Confirm observer callbacks functionality - Add golang.org/x/sys and golang.org/x/term dependencies --- go-tui/go.mod | 2 ++ go-tui/go.sum | 4 ++++ go-tui/main.go | 18 ++++++++++++++---- go-tui/redirect_unix.go | 30 ++++++++++++++++++++++++++++++ go-tui/redirect_windows.go | 23 +++++++++++++++++++++++ 5 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 go-tui/redirect_unix.go create mode 100644 go-tui/redirect_windows.go diff --git a/go-tui/go.mod b/go-tui/go.mod index f63d27f60..cd6157ac5 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -20,4 +20,6 @@ require ( github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/x448/float16 v0.8.4 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect ) diff --git a/go-tui/go.sum b/go-tui/go.sum index 90c95ca37..d5d400676 100644 --- a/go-tui/go.sum +++ b/go-tui/go.sum @@ -24,5 +24,9 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-tui/main.go b/go-tui/main.go index 0c08fe09b..8b59775a2 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -56,9 +56,19 @@ type App struct { } func main() { - // Suppress Ditto logs - os.Setenv("RUST_LOG", "warn") - os.Setenv("RUST_BACKTRACE", "0") + // Platform-specific stderr redirection (Unix: /dev/null, Windows: no-op for now) + redirectStderr() + + // Also redirect Go's log output to a file for debugging + logPath := filepath.Join(os.TempDir(), "ditto-tasks-termui.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err == nil { + log.SetOutput(logFile) + defer logFile.Close() + } + + // Set Ditto log level to Error to suppress most logs + ditto.SetLogLevel(ditto.LogLevelError) // Load environment variables if err := loadEnv(); err != nil { @@ -117,7 +127,7 @@ func main() { if err != nil { log.Fatal("Failed to set expiration handler:", err) } - + // Explicitly login after setting handler log.Printf("Logging in with development token...") provider := ditto.AuthenticationProviderDevelopment() diff --git a/go-tui/redirect_unix.go b/go-tui/redirect_unix.go new file mode 100644 index 000000000..41c0b4b34 --- /dev/null +++ b/go-tui/redirect_unix.go @@ -0,0 +1,30 @@ +//go:build !windows +// +build !windows + +package main + +import ( + "os" + "syscall" + + "golang.org/x/term" +) + +// isTerminal checks if the given file descriptor is a terminal +func isTerminal(fd uintptr) bool { + return term.IsTerminal(int(fd)) +} + +// redirectStderr redirects stderr to /dev/null on Unix systems +func redirectStderr() { + // Redirect stderr to /dev/null so it doesn't interfere with TUI output + // This is similar to what the C++ TUI does with freopen + if isTerminal(os.Stderr.Fd()) { + devNull, err := os.OpenFile("/dev/null", os.O_WRONLY, 0) + if err == nil { + // Redirect stderr to /dev/null + syscall.Dup2(int(devNull.Fd()), int(os.Stderr.Fd())) + devNull.Close() + } + } +} diff --git a/go-tui/redirect_windows.go b/go-tui/redirect_windows.go new file mode 100644 index 000000000..e11a60fd9 --- /dev/null +++ b/go-tui/redirect_windows.go @@ -0,0 +1,23 @@ +//go:build windows +// +build windows + +package main + +import ( + "os" + + "golang.org/x/term" +) + +// isTerminal checks if the given file descriptor is a terminal +func isTerminal(fd uintptr) bool { + return term.IsTerminal(int(fd)) +} + +// redirectStderr is a no-op on Windows for now +// TODO: Implement Windows-specific stderr redirection if needed +func redirectStderr() { + // On Windows, we don't redirect stderr for now + // The terminal UI might still work, or we could implement + // Windows-specific redirection using Windows API calls +} From be2d714577c825b05a2760229d4903ebc8320456 Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Wed, 13 Aug 2025 14:23:00 -0400 Subject: [PATCH 13/38] fix: resolve terminal UI display issues and clean up debug code - Fixed terminal initialization to properly show UI components - Removed debug logging statements that cluttered output - Cleaned up temporary test file handling - Application now displays correctly in terminal --- go-tui/main.go | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index 8b59775a2..57b4d469b 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -65,10 +65,12 @@ func main() { if err == nil { log.SetOutput(logFile) defer logFile.Close() + } else { + log.Printf("Failed to open log file: %v", err) } // Set Ditto log level to Error to suppress most logs - ditto.SetLogLevel(ditto.LogLevelError) + // ditto.SetLogLevel(ditto.LogLevelError) // Commented out - causing segfault // Load environment variables if err := loadEnv(); err != nil { @@ -129,7 +131,6 @@ func main() { } // Explicitly login after setting handler - log.Printf("Logging in with development token...") provider := ditto.AuthenticationProviderDevelopment() err = auth.Login(token, provider, func(clientInfo map[string]interface{}, err error) { if err != nil { @@ -197,17 +198,7 @@ func main() { } app.observer = observer - // Force an initial query synchronously - result, err := d.Store().Execute("SELECT * FROM tasks WHERE deleted = false ORDER BY _id") - if err != nil { - log.Printf("Failed to execute initial query: %v", err) - } else if result != nil { - tasks := parseTasks(result) - select { - case app.tasksChan <- tasks: - case <-app.ctx.Done(): - } - } + // Skip initial query for now - let observer handle it // Run the app app.Run() From 2c95aee7c9de4f345ed799c434d12007788b9509 Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Fri, 15 Aug 2025 11:56:33 -0400 Subject: [PATCH 14/38] refactor: remove call to deprecated DisableSyncWithV3 - Remove DisableSyncWithV3() call from main initialization - Function has been removed from Go SDK - No longer needed for v4+ SDK compatibility --- go-tui/main.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index 57b4d469b..b9c217623 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -156,11 +156,6 @@ func main() { log.Fatal("Failed to configure transport:", err) } - // Disable sync with v3 peers (required for DQL) - if err := d.DisableSyncWithV3(); err != nil { - log.Printf("Warning: Failed to disable sync with v3: %v", err) - } - // Start sync (authentication handler will be called automatically if needed) if err := d.StartSync(); err != nil { log.Fatal("Failed to start sync:", err) From a5b81e91ad3aa9a6e43c3eb0248719abd9e7b472 Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Mon, 18 Aug 2025 09:52:42 -0400 Subject: [PATCH 15/38] chore(go-tui): make the path to the Go SDK machine-independent --- go-tui/go.mod | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go-tui/go.mod b/go-tui/go.mod index cd6157ac5..b09390683 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -11,7 +11,8 @@ require ( github.com/joho/godotenv v1.5.1 ) -replace github.com/getditto/ditto-go-sdk => /Users/kristopherjohnson/work/go-sdk/sdks/go +// TODO(go): remove this line when the Go SDK is published to the web +replace github.com/getditto/ditto-go-sdk => ../../ditto/sdks/go require ( github.com/fxamacker/cbor/v2 v2.5.0 // indirect From dfab631186eda9f9c1f0f341c59998e532cbabc7 Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Mon, 18 Aug 2025 17:09:29 -0400 Subject: [PATCH 16/38] chore(go-tui): remove unnecessary call to `UpdateTransportConfig()` --- go-tui/.editorconfig | 2 ++ go-tui/.gitignore | 1 + go-tui/go.mod | 4 ++-- go-tui/main.go | 16 ++-------------- 4 files changed, 7 insertions(+), 16 deletions(-) create mode 100644 go-tui/.editorconfig diff --git a/go-tui/.editorconfig b/go-tui/.editorconfig new file mode 100644 index 000000000..d407bf299 --- /dev/null +++ b/go-tui/.editorconfig @@ -0,0 +1,2 @@ +[{*.go,*.go2}] +indent_style = tab diff --git a/go-tui/.gitignore b/go-tui/.gitignore index 3712a4d49..f3364a174 100644 --- a/go-tui/.gitignore +++ b/go-tui/.gitignore @@ -1 +1,2 @@ ditto-tasks-termui +go-tui diff --git a/go-tui/go.mod b/go-tui/go.mod index b09390683..ca897bdad 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -1,6 +1,6 @@ module github.com/getditto/quickstart/go-tui -go 1.23.0 +go 1.24 toolchain go1.24.5 @@ -9,6 +9,7 @@ require ( github.com/gizak/termui/v3 v3.1.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + golang.org/x/term v0.34.0 ) // TODO(go): remove this line when the Go SDK is published to the web @@ -22,5 +23,4 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect ) diff --git a/go-tui/main.go b/go-tui/main.go index b9c217623..e470a60a2 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -144,18 +144,6 @@ func main() { } } - // Configure transport - err = d.UpdateTransportConfig(func(tc *ditto.TransportConfig) { - tc.PeerToPeer.BluetoothLE.Enabled = true - tc.PeerToPeer.LAN.Enabled = true - tc.PeerToPeer.LAN.MDNSEnabled = true - tc.PeerToPeer.LAN.MulticastEnabled = true - tc.SetWebsocketURLs([]string{websocketURL}) - }) - if err != nil { - log.Fatal("Failed to configure transport:", err) - } - // Start sync (authentication handler will be called automatically if needed) if err := d.StartSync(); err != nil { log.Fatal("Failed to start sync:", err) @@ -175,6 +163,7 @@ func main() { if err != nil { log.Fatal("Failed to register subscription:", err) } + defer subscription.Cancel() app.subscription = subscription // Create observer for local changes @@ -191,10 +180,9 @@ func main() { if err != nil { log.Fatal("Failed to register observer:", err) } + defer observer.Cancel() app.observer = observer - // Skip initial query for now - let observer handle it - // Run the app app.Run() } From f04c63e4d0186a14a5980ce3287ccab03ed93b6f Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Thu, 21 Aug 2025 10:49:35 -0400 Subject: [PATCH 17/38] fix(go-tui): update API calls to match Go SDK v5 interface - Change Authenticator() to Auth() method calls - Remove error assignment from SetExpirationHandler (now infallible) --- go-tui/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index e470a60a2..a1604ae19 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -110,12 +110,12 @@ func main() { defer d.Close() // Set up authentication handler for development mode - if auth := d.Authenticator(); auth != nil { - err = auth.SetExpirationHandler(func(dit *ditto.Ditto, timeUntilExpiration time.Duration) { + if auth := d.Auth(); auth != nil { + auth.SetExpirationHandler(func(dit *ditto.Ditto, timeUntilExpiration time.Duration) { log.Printf("Expiration handler called with time: %v", timeUntilExpiration) // For development mode, login with the playground token provider := ditto.AuthenticationProviderDevelopment() - err := dit.Authenticator().Login(token, provider, func(clientInfo map[string]interface{}, err error) { + err := dit.Auth().Login(token, provider, func(clientInfo map[string]interface{}, err error) { if err != nil { log.Printf("Login failed: %v", err) } else { From fbb05dbd4ef9e8487b419caf9d90d19b4ab07d6e Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Thu, 21 Aug 2025 11:03:38 -0400 Subject: [PATCH 18/38] fix(go-tui): update ServerConnect to DittoConfigConnectServer - Changed ServerConnect type to DittoConfigConnectServer to match renamed type in Go SDK v5 - Maintains consistency with other DittoConfig connection types --- go-tui/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-tui/main.go b/go-tui/main.go index a1604ae19..688e46d59 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -98,7 +98,7 @@ func main() { config := &ditto.DittoConfig{ DatabaseID: appID, PersistenceDirectory: tempDir, - Connect: &ditto.ServerConnect{ + Connect: &ditto.DittoConfigConnectServer{ URL: authURL, }, } From ba1152646bf1e2ee68b1c8e1f80fac5acce38cef Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Thu, 21 Aug 2025 11:54:23 -0400 Subject: [PATCH 19/38] fix(go-tui): update sync API calls to use new Sync type methods Update StartSync() call to use d.Sync().Start() to match the refactored Go SDK API where sync methods have been moved from Ditto to Sync type. --- go-tui/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-tui/main.go b/go-tui/main.go index 688e46d59..12e267c5a 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -145,7 +145,7 @@ func main() { } // Start sync (authentication handler will be called automatically if needed) - if err := d.StartSync(); err != nil { + if err := d.Sync().Start(); err != nil { log.Fatal("Failed to start sync:", err) } From 6b1dd4d1ad3b75109ae9e8a5ca02e794bb83dfbe Mon Sep 17 00:00:00 2001 From: Kris Johnson Date: Fri, 22 Aug 2025 12:25:44 -0400 Subject: [PATCH 20/38] perf(go-tui): use Items() method instead of iterating GetItem() Replace individual GetItem(i) calls with Items() method for better performance when parsing query results. --- go-tui/main.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index 12e267c5a..14399fd0d 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -534,11 +534,9 @@ func parseTasks(result *ditto.QueryResult) []Task { // Don't pre-allocate when we're filtering var tasks []Task - for i := 0; i < result.ItemCount(); i++ { - queryItem, err := result.GetItem(i) - if err != nil { - continue - } + items := result.Items() + for i := 0; i < len(items); i++ { + queryItem := items[i] // Get the value as a map if queryItem == nil || queryItem.Value == nil { From 451d5b704baca6c21ee086f28b093f6690d1c6aa Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Tue, 26 Aug 2025 10:51:49 -0400 Subject: [PATCH 21/38] chore(go): rename Go quickstart executable to `ditto-tasks-termui` --- go-tui/Makefile | 41 +++++++++++++++++++++++++++++++++++++++++ go-tui/go.mod | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 go-tui/Makefile diff --git a/go-tui/Makefile b/go-tui/Makefile new file mode 100644 index 000000000..8c0634e65 --- /dev/null +++ b/go-tui/Makefile @@ -0,0 +1,41 @@ +# Makefile for Ditto Go TUI Tasks Application +# Display help information +help: + @echo "Ditto Go TUI Tasks Application" + @echo "" + @echo "Available targets:" + @echo " build - Build the FFI library and application" + @echo " run - Build and run the application" + @echo " run-go - Run directly with 'go run'" + @echo " ffi - Build only the FFI library" + @echo " clean - Remove build artifacts" + @echo " help - Show this help message" + @echo "" + @echo "Note: Ensure .env file exists with proper Ditto configuration" + +.PHONY: build run clean help ffi + +# Build the FFI library (required for Ditto SDK) +ffi: + @echo "Building FFI library..." + (cd ../../ditto/sdks/go && make build) + +# Build the application +build: ffi + @echo "Building ditto-tasks-termui..." + go build -o ditto-tasks-termui + +# Run the application (built binary) +run: build + @echo "Running ditto-tasks-termui..." + ./ditto-tasks-termui 2>/dev/null + +# Run directly with Go +run-go: + @echo "Running with go run..." + go run main.go 2>/dev/null + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -f ditto-tasks-termui diff --git a/go-tui/go.mod b/go-tui/go.mod index ca897bdad..0783d91a3 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -1,4 +1,4 @@ -module github.com/getditto/quickstart/go-tui +module github.com/getditto/quickstart/go-tui/ditto-tasks-termui go 1.24 From ba988f61dbba0ad7209e42400d361ff6edd58425 Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Tue, 26 Aug 2025 10:54:26 -0400 Subject: [PATCH 22/38] chore(go): add dsharp-pivotal to CODEOWNERS for go-tui --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6bef2289d..fe02ef05c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,7 +14,7 @@ /dotnet-maui/ @busec0 @phatblat /dotnet-tui/ @busec0 @phatblat /flutter_quickstart/ @cameron1024 @teodorciuraru -/go-tui/ @kristopherjohnson +/go-tui/ @kristopherjohnson @dsharp-pivotal /javascript-tui/ @konstantinbe @pvditto @teodorciuraru /javascript-web/ @konstantinbe @pvditto @teodorciuraru /react-native/ @teodorciuraru @kristopherjohnson From 9e39ddf802e847fcb058b42242c8a7c2a4fd34c1 Mon Sep 17 00:00:00 2001 From: David Sharp Date: Tue, 26 Aug 2025 14:54:17 -0700 Subject: [PATCH 23/38] go-tui: Use os.DevNull for name of /dev/null --- go-tui/redirect_unix.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-tui/redirect_unix.go b/go-tui/redirect_unix.go index 41c0b4b34..8d4f4f5f1 100644 --- a/go-tui/redirect_unix.go +++ b/go-tui/redirect_unix.go @@ -20,7 +20,7 @@ func redirectStderr() { // Redirect stderr to /dev/null so it doesn't interfere with TUI output // This is similar to what the C++ TUI does with freopen if isTerminal(os.Stderr.Fd()) { - devNull, err := os.OpenFile("/dev/null", os.O_WRONLY, 0) + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) if err == nil { // Redirect stderr to /dev/null syscall.Dup2(int(devNull.Fd()), int(os.Stderr.Fd())) From 390fbfcd2706ab36b3fa59fac89be4d904afbf00 Mon Sep 17 00:00:00 2001 From: David Sharp Date: Tue, 26 Aug 2025 15:26:37 -0700 Subject: [PATCH 24/38] go-tui: Use channels over Mutex in the App struct --- go-tui/main.go | 69 +++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index 14399fd0d..4c3ac57a7 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "strings" - "sync" "time" "github.com/getditto/ditto-go-sdk/ditto" @@ -36,17 +35,23 @@ type App struct { ditto *ditto.Ditto observer *ditto.StoreObserver subscription *ditto.SyncSubscription - tasks []Task - selectedIdx int - inputMode InputMode - inputBuffer string - editingID string - tasksChan chan []Task - errorMsg string - errorTimer *time.Timer - ctx context.Context - cancel context.CancelFunc - mu sync.RWMutex + + tasks []Task + tasksChan chan []Task + selectedIdx int + + inputMode InputMode + inputBuffer string + editingID string + + errorMsg struct { + value string // Owned by the app goroutine + ch chan string + reset <-chan time.Time + } + + ctx context.Context + cancel context.CancelFunc // UI widgets taskTable *widgets.Table @@ -199,6 +204,8 @@ func NewApp(d *ditto.Ditto) *App { cancel: cancel, } + app.errorMsg.ch = make(chan string, 1) + // Create widgets app.taskTable = widgets.NewTable() app.taskTable.Title = " Tasks (j↓, k↑, ⏎ toggle done) " @@ -256,6 +263,12 @@ func (a *App) Run() { case tasks := <-a.tasksChan: a.updateTasks(tasks) a.render() + case msg := <-a.errorMsg.ch: + a.errorMsg.value = msg + a.render() + case <-a.errorMsg.reset: + a.errorMsg.value = "" + a.render() } } } @@ -274,19 +287,15 @@ func (a *App) handleEvent(e ui.Event) { func (a *App) handleNormalMode(e ui.Event) { switch e.ID { case "j", "": - a.mu.Lock() if a.selectedIdx < len(a.tasks)-1 { a.selectedIdx++ } - a.mu.Unlock() a.render() case "k", "": - a.mu.Lock() if a.selectedIdx > 0 { a.selectedIdx-- } - a.mu.Unlock() a.render() case "", " ": @@ -398,28 +407,17 @@ func (a *App) render() { } // Render error if present - if a.errorMsg != "" { - a.errorBar.Text = fmt.Sprintf("Error: %s", a.errorMsg) + if a.errorMsg.value != "" { + a.errorBar.Text = fmt.Sprintf("Error: %s", a.errorMsg.value) a.errorBar.SetRect(0, termHeight-3, termWidth, termHeight-2) ui.Render(a.errorBar) - // Clear error after 3 seconds using time.AfterFunc - if a.errorTimer != nil { - a.errorTimer.Stop() - } - a.errorTimer = time.AfterFunc(3*time.Second, func() { - a.mu.Lock() - a.errorMsg = "" - a.mu.Unlock() - a.render() - }) + // Clear error after 3 seconds using the reset channel + a.errorMsg.reset = time.After(3 * time.Second) } } func (a *App) updateTable() { - a.mu.RLock() - defer a.mu.RUnlock() - // Headers headers := []string{"", "Done", "Title"} @@ -504,10 +502,9 @@ func (a *App) deleteTask(id string) { } } +// Set the UI error message. May be called by any goroutine. func (a *App) setError(msg string) { - a.mu.Lock() - a.errorMsg = msg - a.mu.Unlock() + a.errorMsg.ch <- msg } func loadEnv() error { @@ -563,8 +560,6 @@ func parseTasks(result *ditto.QueryResult) []Task { // getSelectedTask returns the currently selected task and whether the selection is valid func (a *App) getSelectedTask() (Task, bool) { - a.mu.RLock() - defer a.mu.RUnlock() if a.selectedIdx < len(a.tasks) { return a.tasks[a.selectedIdx], true } @@ -573,8 +568,6 @@ func (a *App) getSelectedTask() (Task, bool) { // updateTasks updates the task list and adjusts selection if needed func (a *App) updateTasks(tasks []Task) { - a.mu.Lock() - defer a.mu.Unlock() a.tasks = tasks if a.selectedIdx >= len(a.tasks) && len(a.tasks) > 0 { a.selectedIdx = len(a.tasks) - 1 From 4f183bc72548dbafb17a11635a7811a463d9327b Mon Sep 17 00:00:00 2001 From: David Sharp Date: Tue, 26 Aug 2025 15:34:23 -0700 Subject: [PATCH 25/38] go-tui: Clear highlight from non-selected rows - Clear the RowStyles map so the previous selection is removed. - termui.Table.TextStyle is used as the default style for rows not in the RowStyles map. --- go-tui/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go-tui/main.go b/go-tui/main.go index 4c3ac57a7..e28a981e8 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -212,7 +212,7 @@ func NewApp(d *ditto.Ditto) *App { app.taskTable.BorderStyle = ui.NewStyle(ui.ColorCyan) app.taskTable.RowSeparator = false app.taskTable.FillRow = true - app.taskTable.RowStyles[0] = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) + app.taskTable.TextStyle = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) app.inputBox = widgets.NewParagraph() app.inputBox.Title = " New Task " @@ -444,6 +444,7 @@ func (a *App) updateTable() { a.taskTable.Rows = rows // Highlight selected row + a.taskTable.RowStyles = map[int]ui.Style{} // clear existing highlight(s) if a.selectedIdx >= 0 && a.selectedIdx < len(a.tasks) { a.taskTable.RowStyles[a.selectedIdx+1] = ui.NewStyle(ui.ColorBlue, ui.ColorClear, ui.ModifierBold) } From 603b78b5b9d8c603fd14ec788633e63074cb5f2e Mon Sep 17 00:00:00 2001 From: David Sharp Date: Tue, 26 Aug 2025 16:06:04 -0700 Subject: [PATCH 26/38] go-tui: Shrink the selector and done columns to fixed sizes --- go-tui/main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index e28a981e8..0dd236d90 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -429,9 +429,9 @@ func (a *App) updateTable() { selector = "❯❯" } - done := "☐" + done := " ☐" if task.Done { - done = "✅" + done = " ✓" } rows = append(rows, []string{selector, done, task.Title}) @@ -442,6 +442,7 @@ func (a *App) updateTable() { } a.taskTable.Rows = rows + a.taskTable.ColumnWidths = []int{2, 5, a.taskTable.Dx() - 7} // Highlight selected row a.taskTable.RowStyles = map[int]ui.Style{} // clear existing highlight(s) From c2fe91dc891b9be0a0ec1cb6130fbd7a978f2d4a Mon Sep 17 00:00:00 2001 From: David Sharp Date: Tue, 26 Aug 2025 16:43:27 -0700 Subject: [PATCH 27/38] go-tui: Re-render on terminal resize --- go-tui/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go-tui/main.go b/go-tui/main.go index 0dd236d90..0e65c9c86 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -256,6 +256,8 @@ func (a *App) Run() { a.editingID = "" a.render() } + case "": + a.render() default: a.handleEvent(e) } From fbcda3c4e3862366378028dfd046a921012fb351 Mon Sep 17 00:00:00 2001 From: David Sharp Date: Tue, 26 Aug 2025 16:43:57 -0700 Subject: [PATCH 28/38] go-tui: Update input box size The main task table was also making room for the input box when in edit/create modes, but no room is needed since the input box is rendered over the task table. --- go-tui/main.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index 0e65c9c86..c11d2137a 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -375,9 +375,6 @@ func (a *App) render() { // Layout calculations tableHeight := termHeight - 3 // Leave room for status bar - if a.inputMode != NormalMode { - tableHeight = termHeight - 8 // Make room for input box - } // Set widget positions a.taskTable.SetRect(0, 0, termWidth, tableHeight) @@ -400,7 +397,7 @@ func (a *App) render() { if boxWidth > 60 { boxWidth = 60 } - boxHeight := 3 + boxHeight := 8 boxX := (termWidth - boxWidth) / 2 boxY := (termHeight - boxHeight) / 2 From bee75f162b4a59780387cf03961a7aa5e5a6add1 Mon Sep 17 00:00:00 2001 From: David Sharp Date: Tue, 26 Aug 2025 17:08:01 -0700 Subject: [PATCH 29/38] git-tui: Handle all ui events in handleEvent; fix "q" in edit modes In edit mode, it was not possible to type the letter "q" because the main event loop was swallowing the event. This event switch was over-complicated anyway, and pushing all these cases down into handleEvent/handle{Normal,Input}Mode lets them serve their purpose better. --- go-tui/main.go | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/go-tui/main.go b/go-tui/main.go index c11d2137a..70c71b6c6 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -244,23 +244,7 @@ func (a *App) Run() { case <-a.ctx.Done(): return case e := <-uiEvents: - switch e.ID { - case "q", "": - if a.inputMode == NormalMode { - return - } - case "": - if a.inputMode != NormalMode { - a.inputMode = NormalMode - a.inputBuffer = "" - a.editingID = "" - a.render() - } - case "": - a.render() - default: - a.handleEvent(e) - } + a.handleEvent(e) case tasks := <-a.tasksChan: a.updateTasks(tasks) @@ -276,6 +260,10 @@ func (a *App) Run() { } func (a *App) handleEvent(e ui.Event) { + if e.ID == "" { + a.render() + return + } switch a.inputMode { case NormalMode: a.handleNormalMode(e) @@ -288,6 +276,8 @@ func (a *App) handleEvent(e ui.Event) { func (a *App) handleNormalMode(e ui.Event) { switch e.ID { + case "q", "": + a.cancel() // signal main event loop to exit case "j", "": if a.selectedIdx < len(a.tasks)-1 { a.selectedIdx++ @@ -332,6 +322,11 @@ func (a *App) handleNormalMode(e ui.Event) { func (a *App) handleInputMode(e ui.Event, isEdit bool) { switch e.ID { + case "": + a.inputMode = NormalMode + a.inputBuffer = "" + a.editingID = "" + a.render() case "": if strings.TrimSpace(a.inputBuffer) != "" { if isEdit { From 78ea368ec3377531c7b66d237bc2523c2f432a85 Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Mon, 3 Nov 2025 17:12:59 -0500 Subject: [PATCH 30/38] update go-tui for Go SDK public preview.1 --- go-tui/.gitignore | 2 + go-tui/Makefile | 59 +++++++++++++++----- go-tui/README.md | 109 +++++++++++++++++------------------- go-tui/go.mod | 13 ++--- go-tui/go.sum | 8 +-- go-tui/main.go | 111 +++++++++++++++---------------------- go-tui/redirect_unix.go | 16 ++++-- go-tui/redirect_windows.go | 2 - go-tui/widgets.go | 18 ++++++ 9 files changed, 183 insertions(+), 155 deletions(-) create mode 100644 go-tui/widgets.go diff --git a/go-tui/.gitignore b/go-tui/.gitignore index f3364a174..0420c81fa 100644 --- a/go-tui/.gitignore +++ b/go-tui/.gitignore @@ -1,2 +1,4 @@ ditto-tasks-termui go-tui +*.log +*.out diff --git a/go-tui/Makefile b/go-tui/Makefile index 8c0634e65..d3a6c17f4 100644 --- a/go-tui/Makefile +++ b/go-tui/Makefile @@ -1,5 +1,7 @@ -# Makefile for Ditto Go TUI Tasks Application +# Makefile for Ditto QuickStart Go TUI Tasks application + # Display help information +.PHONY: help help: @echo "Ditto Go TUI Tasks Application" @echo "" @@ -7,35 +9,66 @@ help: @echo " build - Build the FFI library and application" @echo " run - Build and run the application" @echo " run-go - Run directly with 'go run'" - @echo " ffi - Build only the FFI library" @echo " clean - Remove build artifacts" @echo " help - Show this help message" @echo "" @echo "Note: Ensure .env file exists with proper Ditto configuration" -.PHONY: build run clean help ffi +GO=go + +# Ditto SDK version and platform detection +DITTO_SDK_VERSION ?= 5.0.0-go-preview.1 +PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]') +ARCH := $(shell uname -m) + +# Determine Ditto SDK platform string +ifeq ($(PLATFORM),linux) + DITTO_PLATFORM = go-linux-$(ARCH) +else ifeq ($(PLATFORM),darwin) + DITTO_PLATFORM = go-macos-aarch64 +else + $(error Unsupported platform: $(PLATFORM)) +endif -# Build the FFI library (required for Ditto SDK) -ffi: - @echo "Building FFI library..." - (cd ../../ditto/sdks/go && make build) +DITTO_SDK_URL = https://software.ditto.live/$(DITTO_PLATFORM)/Ditto/$(DITTO_SDK_VERSION)/dist/Ditto.tar.gz # Build the application -build: ffi +.PHONY: build +build: go-sdk ditto-tasks-termui + +ditto-tasks-termui: @echo "Building ditto-tasks-termui..." - go build -o ditto-tasks-termui + $(GO) build -o ditto-tasks-termui + +.PHONY: go-sdk +go-sdk: ## Downloads and installs the Ditto Go SDK library to go-sdk directory + @if [ ! -f go-sdk/libdittoffi.so ] && [ ! -f go-sdk/libdittoffi.dylib ] ; then \ + @echo "📥 Downloading Ditto Go SDK v$(DITTO_SDK_VERSION) for $(DITTO_PLATFORM)..." + mkdir -p go-sdk; \ + if curl -L -f $(DITTO_SDK_URL) | tar xz --strip-components=0 -C go-sdk/; then \ + echo "✅ Ditto Go SDK v$(DITTO_SDK_VERSION) installed successfully"; \ + else \ + echo "❌ Failed to download SDK for $(DITTO_PLATFORM)"; \ + fi; \ + else \ + echo "✅ Ditto Go SDK already installed"; \ + fi # Run the application (built binary) +.PHONY: BUILD_TYPE run: build @echo "Running ditto-tasks-termui..." - ./ditto-tasks-termui 2>/dev/null + LD_LIBRARY_PATH="$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$(pwd)/go-sdk" ./ditto-tasks-termui 2>/dev/null # Run directly with Go +.PHONY: run-go run-go: - @echo "Running with go run..." - go run main.go 2>/dev/null + @echo "Running ditto tasks-termui with go run..." + LD_LIBRARY_PATH="$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$(pwd)/go-sdk" $(GO) run main.go 2>/dev/null # Clean build artifacts +.PHONY: clean clean: - @echo "Cleaning build artifacts..." + @echo "Cleaning ditto-tasks-termui and build artifacts..." rm -f ditto-tasks-termui + rm -f go-sdk diff --git a/go-tui/README.md b/go-tui/README.md index a8a6f180e..17e26f69f 100644 --- a/go-tui/README.md +++ b/go-tui/README.md @@ -12,43 +12,57 @@ AppID and Online Playground Token, Auth URL, and Websocket URL in order to use t [0]: https://portal.ditto.live -From the repo root, copy the `.env.sample` file to `.env`, and fill in the -fields with your AppID, Online Playground Token, Auth URL, and Websocket URL: +Create a `.env` file in this directory with your Ditto credentials: -``` -cp ../../.env.sample ../../.env +```bash +# Create .env file +cat > .env << 'EOF' +DITTO_APP_ID=your-app-id +DITTO_PLAYGROUND_TOKEN=your-playground-token +DITTO_AUTH_URL=https://your-app-id.cloud.ditto.live +EOF ``` -The `.env` file should look like this (with your fields filled in): +Alternatively, you can set these as environment variables: ```bash -#!/usr/bin/env bash - -# Copy this file from ".env.sample" to ".env", then fill in these values -# A Ditto AppID, Online Playground Token, Auth URL, and Websocket URL can be obtained from https://portal.ditto.live -export DITTO_APP_ID="" -export DITTO_PLAYGROUND_TOKEN="" -export DITTO_AUTH_URL="" -export DITTO_WEBSOCKET_URL="" +export DITTO_APP_ID="your-app-id" +export DITTO_PLAYGROUND_TOKEN="your-playground-token" +export DITTO_AUTH_URL="https://your-app-id.cloud.ditto.live" ``` ## Building -First, build the FFI library (required for Ditto SDK): -```bash -(cd ../../ditto/sdks/go && make build) -``` +From this directory (`go-tui`): -Then build the application: ```bash +# Using the Makefile +make build + +# Or build directly with Go go build -o ditto-tasks-termui ``` + ## Running +**Note:** the Ditto Go SDK `libdittoffi.so` (Linux) or `libdittoffi.dylib` +shared library must be present and in one of the directories searched by the +system's dynamic linker to run the application. This is handled automatically +by `make run`. If you use one of the other options, you may need to perform +additional steps. See the [Go SDK Install Guide](https://docs.ditto.live/sdk/latest/install-guides/go) +for details. + Run the quickstart app with the following command: + +```bash +# Using the Makefile (which will download the shared library and set shared-library load paths automatically) +make run +``` + ```bash +# Run the executable that was created via make build ./ditto-tasks-termui 2>/dev/null ``` @@ -75,51 +89,35 @@ go run main.go 2>/dev/null ## Features - ✅ Create, edit, and delete tasks -- ✅ Mark tasks as complete/incomplete +- ✅ Mark tasks as complete/incomplete - ✅ Real-time synchronization across devices - ✅ Terminal-based interface using termui - ✅ Cross-platform compatibility with other Ditto quickstart apps -## Data Model - -Tasks are stored in a `tasks` collection with the following structure: -```json -{ - "_id": "unique-task-id", - "title": "Task description", - "done": false, - "deleted": false -} -``` -This matches the data model used by other quickstart apps (Rust, C++, etc.) for cross-platform sync compatibility. - -## UI Features +## Troubleshooting -The TUI displays tasks in a table format with: -- Selection indicator (❯❯) for the currently selected task -- Checkboxes showing task status (✅ for done, ☐ for not done) -- Modal overlay for creating and editing tasks -- Keyboard shortcut hints in the status bar +### Logs -## Troubleshooting +To find errors and messages that are not printed to the TUI display, check the application logs. +Logs are output to `/tmp/ditto-tasks-termui.log`. -### Library not found -If you get a library loading error, ensure the FFI library is built and available: -```bash -export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:../../ditto/sdks/go/build -``` +### libdittoffi Library not found -On macOS, you may need to use `DYLD_LIBRARY_PATH` instead: -```bash -export DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:../../ditto/sdks/go/build -``` +If you get a library loading error, ensure that the `libdittoffi.so` (Linux) or +`libdittoffi.dylib` (macOS) shared library is present and that `LD_LIBRARY_PATH` +(Linux) or `DYLD_LIBRARY_PATH` (macOS) is set appropriately. ### Environment variables not found -The app looks for `.env` file in parent directories. Ensure it exists in the repository root with all required variables set. + +The app looks for a `.env` file in the current directory. Ensure it exists with +all required variables set, or export them as environment variables. ### Garbled screen output -Always run the application with `2>/dev/null` to suppress stderr output that can interfere with the TUI display: + +Always run the application with `2>/dev/null` to suppress stderr output that can +interfere with the TUI display: + ```bash ./ditto-tasks-termui 2>/dev/null ``` @@ -128,17 +126,14 @@ Always run the application with `2>/dev/null` to suppress stderr output that can The application uses: - [termui v3](https://github.com/gizak/termui) for the TUI framework (similar to Rust's ratatui) -- [Ditto Go SDK](https://docs.ditto.live) for real-time sync +- [Ditto Go SDK](https://github.com/getditto/ditto-go-sdk) for edge sync - Channels for async communication between Ditto observers and the UI ## Architecture -The app follows an event-driven architecture similar to the Rust TUI implementation: +The app follows an event-driven architecture: - Direct event loop handling keyboard input -- Table widget for displaying tasks +- Table widget for displaying tasks (similar to Rust's ratatui) - Manual text input handling for create/edit modes - Async updates from Ditto observers via Go channels - -## License - -MIT \ No newline at end of file +- Real-time sync with other Ditto peers running the same app diff --git a/go-tui/go.mod b/go-tui/go.mod index 0783d91a3..8d6aa95d1 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -1,20 +1,16 @@ module github.com/getditto/quickstart/go-tui/ditto-tasks-termui -go 1.24 - -toolchain go1.24.5 +go 1.23 require ( - github.com/getditto/ditto-go-sdk v0.0.0 + github.com/getditto/ditto-go-sdk v5.0.0-go-preview.1 github.com/gizak/termui/v3 v3.1.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - golang.org/x/term v0.34.0 + golang.org/x/sys v0.36.0 + golang.org/x/term v0.35.0 ) -// TODO(go): remove this line when the Go SDK is published to the web -replace github.com/getditto/ditto-go-sdk => ../../ditto/sdks/go - require ( github.com/fxamacker/cbor/v2 v2.5.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -22,5 +18,4 @@ require ( github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/sys v0.35.0 // indirect ) diff --git a/go-tui/go.sum b/go-tui/go.sum index d5d400676..f2964de1f 100644 --- a/go-tui/go.sum +++ b/go-tui/go.sum @@ -24,9 +24,9 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-tui/main.go b/go-tui/main.go index 70c71b6c6..947c51732 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -75,7 +75,7 @@ func main() { } // Set Ditto log level to Error to suppress most logs - // ditto.SetLogLevel(ditto.LogLevelError) // Commented out - causing segfault + // ditto.SetMinimumLogLevel(ditto.LogLevelError) // Commented out - causing segfault // Load environment variables if err := loadEnv(); err != nil { @@ -86,10 +86,9 @@ func main() { appID := os.Getenv("DITTO_APP_ID") token := os.Getenv("DITTO_PLAYGROUND_TOKEN") authURL := os.Getenv("DITTO_AUTH_URL") - websocketURL := os.Getenv("DITTO_WEBSOCKET_URL") - if appID == "" || token == "" || authURL == "" || websocketURL == "" { - log.Fatal("Missing required environment variables. Please set DITTO_APP_ID, DITTO_PLAYGROUND_TOKEN, DITTO_AUTH_URL, and DITTO_WEBSOCKET_URL") + if appID == "" || token == "" || authURL == "" { + log.Fatal("Missing required environment variables. Please set DITTO_APP_ID, DITTO_PLAYGROUND_TOKEN, and DITTO_AUTH_URL") } // Create temp directory for persistence @@ -100,13 +99,10 @@ func main() { defer os.RemoveAll(tempDir) // Initialize Ditto with Server connection API - config := &ditto.DittoConfig{ - DatabaseID: appID, - PersistenceDirectory: tempDir, - Connect: &ditto.DittoConfigConnectServer{ - URL: authURL, - }, - } + config := ditto.DefaultDittoConfig(). + WithDatabaseID(appID). + WithPersistenceDirectory(tempDir). + WithConnect(&ditto.DittoConfigConnectServer{URL: authURL}) d, err := ditto.Open(config) if err != nil { @@ -116,37 +112,22 @@ func main() { // Set up authentication handler for development mode if auth := d.Auth(); auth != nil { - auth.SetExpirationHandler(func(dit *ditto.Ditto, timeUntilExpiration time.Duration) { - log.Printf("Expiration handler called with time: %v", timeUntilExpiration) - // For development mode, login with the playground token - provider := ditto.AuthenticationProviderDevelopment() - err := dit.Auth().Login(token, provider, func(clientInfo map[string]interface{}, err error) { + auth.SetExpirationHandler( + func(d *ditto.Ditto, timeUntilExpiration time.Duration) { + log.Printf("Expiration handler called with time until expiration: %v", timeUntilExpiration) + + // For development mode, login with the playground token + provider := ditto.DevelopmentAuthenticationProvider() + clientInfoJSON, err := d.Auth().Login(token, provider) if err != nil { - log.Printf("Login failed: %v", err) + log.Printf("Failed to login: %v", err) } else { log.Printf("Login successful") + if clientInfoJSON != "" { + log.Printf("Client info: %s", clientInfoJSON) + } } }) - if err != nil { - log.Printf("Failed to initiate login: %v", err) - } - }) - if err != nil { - log.Fatal("Failed to set expiration handler:", err) - } - - // Explicitly login after setting handler - provider := ditto.AuthenticationProviderDevelopment() - err = auth.Login(token, provider, func(clientInfo map[string]interface{}, err error) { - if err != nil { - log.Printf("Initial login failed: %v", err) - } else { - log.Printf("Initial login successful: %v", clientInfo) - } - }) - if err != nil { - log.Printf("Failed to initiate initial login: %v", err) - } } // Start sync (authentication handler will be called automatically if needed) @@ -174,14 +155,16 @@ func main() { // Create observer for local changes observer, err := d.Store().RegisterObserver( "SELECT * FROM tasks WHERE deleted = false ORDER BY _id", + nil, func(result *ditto.QueryResult) { + defer result.Close() + tasks := parseTasks(result) select { case app.tasksChan <- tasks: case <-app.ctx.Done(): } - }, - ) + }) if err != nil { log.Fatal("Failed to register observer:", err) } @@ -218,12 +201,10 @@ func NewApp(d *ditto.Ditto) *App { app.inputBox.Title = " New Task " app.inputBox.BorderStyle = ui.NewStyle(ui.ColorMagenta) - app.statusBar = widgets.NewParagraph() - app.statusBar.Border = false + app.statusBar = BorderlessParagraph() app.statusBar.Text = "[c](fg:yellow): create [e](fg:yellow): edit [d](fg:yellow): delete [q](fg:yellow): quit [s](fg:yellow): toggle sync" - app.errorBar = widgets.NewParagraph() - app.errorBar.Border = false + app.errorBar = BorderlessParagraph() app.errorBar.TextStyle = ui.NewStyle(ui.ColorRed) return app @@ -369,11 +350,11 @@ func (a *App) render() { a.updateTable() // Layout calculations - tableHeight := termHeight - 3 // Leave room for status bar + tableHeight := termHeight - 2 // Leave room for status bar // Set widget positions a.taskTable.SetRect(0, 0, termWidth, tableHeight) - a.statusBar.SetRect(0, termHeight-2, termWidth, termHeight) + a.statusBar.SetRect(0, termHeight-1, termWidth, termHeight) // Render main widgets ui.Render(a.taskTable, a.statusBar) @@ -403,7 +384,7 @@ func (a *App) render() { // Render error if present if a.errorMsg.value != "" { a.errorBar.Text = fmt.Sprintf("Error: %s", a.errorMsg.value) - a.errorBar.SetRect(0, termHeight-3, termWidth, termHeight-2) + a.errorBar.SetRect(0, termHeight-2, termWidth, termHeight-1) ui.Render(a.errorBar) // Clear error after 3 seconds using the reset channel @@ -453,17 +434,19 @@ func (a *App) createTask(title string) { "deleted": false, } - _, err := a.ditto.Store().Execute( + result, err := a.ditto.Store().Execute( "INSERT INTO tasks VALUES (:task)", map[string]interface{}{"task": task}, ) if err != nil { a.setError(err.Error()) + return } + defer result.Close() } func (a *App) updateTask(id, title string) { - _, err := a.ditto.Store().Execute( + result, err := a.ditto.Store().Execute( "UPDATE tasks SET title = :title WHERE _id = :id", map[string]interface{}{ "title": title, @@ -472,11 +455,13 @@ func (a *App) updateTask(id, title string) { ) if err != nil { a.setError(err.Error()) + return } + defer result.Close() } func (a *App) toggleTask(id string, done bool) { - _, err := a.ditto.Store().Execute( + result, err := a.ditto.Store().Execute( "UPDATE tasks SET done = :done WHERE _id = :id", map[string]interface{}{ "done": done, @@ -485,17 +470,21 @@ func (a *App) toggleTask(id string, done bool) { ) if err != nil { a.setError(err.Error()) + return } + defer result.Close() } func (a *App) deleteTask(id string) { - _, err := a.ditto.Store().Execute( + result, err := a.ditto.Store().Execute( "UPDATE tasks SET deleted = true WHERE _id = :id", map[string]interface{}{"id": id}, ) if err != nil { a.setError(err.Error()) + return } + defer result.Close() } // Set the UI error message. May be called by any goroutine. @@ -508,8 +497,8 @@ func loadEnv() error { dir, _ := os.Getwd() for { envPath := filepath.Join(dir, ".env") - if _, err := os.Stat(envPath); err == nil { - return godotenv.Load(envPath) + if err := godotenv.Load(envPath); err == nil || !os.IsNotExist(err) { + return err } parent := filepath.Dir(dir) if parent == dir { @@ -528,17 +517,9 @@ func parseTasks(result *ditto.QueryResult) []Task { // Don't pre-allocate when we're filtering var tasks []Task items := result.Items() - for i := 0; i < len(items); i++ { - queryItem := items[i] - + for _, queryItem := range items { // Get the value as a map - if queryItem == nil || queryItem.Value == nil { - continue - } - item, ok := queryItem.Value.(map[string]interface{}) - if !ok { - continue - } + item := queryItem.Value() // Parse the task from the document task := Task{ @@ -571,7 +552,7 @@ func (a *App) updateTasks(tasks []Task) { } // Generic helper for type assertions -func getValueAs[T any](m map[string]interface{}, key string) (T, bool) { +func getValueAs[T any](m map[string]any, key string) (T, bool) { if v, ok := m[key].(T); ok { return v, true } @@ -579,14 +560,14 @@ func getValueAs[T any](m map[string]interface{}, key string) (T, bool) { return zero, false } -func getStringValue(m map[string]interface{}, key string) string { +func getStringValue(m map[string]any, key string) string { if v, ok := getValueAs[string](m, key); ok { return v } return "" } -func getBoolValue(m map[string]interface{}, key string) bool { +func getBoolValue(m map[string]any, key string) bool { if v, ok := getValueAs[bool](m, key); ok { return v } diff --git a/go-tui/redirect_unix.go b/go-tui/redirect_unix.go index 8d4f4f5f1..7f5a8fcbd 100644 --- a/go-tui/redirect_unix.go +++ b/go-tui/redirect_unix.go @@ -4,9 +4,10 @@ package main import ( + "log" "os" - "syscall" + "golang.org/x/sys/unix" "golang.org/x/term" ) @@ -21,10 +22,15 @@ func redirectStderr() { // This is similar to what the C++ TUI does with freopen if isTerminal(os.Stderr.Fd()) { devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) - if err == nil { - // Redirect stderr to /dev/null - syscall.Dup2(int(devNull.Fd()), int(os.Stderr.Fd())) - devNull.Close() + if err != nil { + log.Printf("Failed to open %s: %v", os.DevNull, err) + return + } + defer devNull.Close() + + // Redirect stderr to /dev/null + if err := unix.Dup2(int(devNull.Fd()), int(os.Stderr.Fd())); err != nil { + log.Printf("Failed to redirect stderr: %v", err) } } } diff --git a/go-tui/redirect_windows.go b/go-tui/redirect_windows.go index e11a60fd9..69e16cd74 100644 --- a/go-tui/redirect_windows.go +++ b/go-tui/redirect_windows.go @@ -4,8 +4,6 @@ package main import ( - "os" - "golang.org/x/term" ) diff --git a/go-tui/widgets.go b/go-tui/widgets.go new file mode 100644 index 000000000..959ab2d92 --- /dev/null +++ b/go-tui/widgets.go @@ -0,0 +1,18 @@ +package main + +import "github.com/gizak/termui/v3/widgets" + +// BorderlessParagraph creates a paragraph without borders and removes the default inner padding. +// This allows safely rendering a paragraph that is smaller than three rows or three columns. +// The default calculation of the Inner rectangle in termui.Block#SetRect will produce an invalid rectangle when the +// height or width of the passed coordinates is two or less. Negating the introduced offsets with negative +// values for Padding allows the text to render as expected. +func BorderlessParagraph() *widgets.Paragraph { + p := widgets.NewParagraph() + p.Border = false + p.PaddingLeft = -1 + p.PaddingTop = -1 + p.PaddingRight = -1 + p.PaddingBottom = -1 + return p +} From 30b8bc548377ea39a8ba44853660a1b452c2fc0f Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Mon, 3 Nov 2025 17:22:09 -0500 Subject: [PATCH 31/38] Delete GO_IMPROVEMENTS.md --- GO_IMPROVEMENTS.md | 352 --------------------------------------------- 1 file changed, 352 deletions(-) delete mode 100644 GO_IMPROVEMENTS.md diff --git a/GO_IMPROVEMENTS.md b/GO_IMPROVEMENTS.md deleted file mode 100644 index e772757cb..000000000 --- a/GO_IMPROVEMENTS.md +++ /dev/null @@ -1,352 +0,0 @@ -# Go-idiomatic Improvements for go-tui - -## Overview -The go-tui code was translated from Rust and contains several patterns that, while functional, could be more idiomatic Go. These improvements maintain all existing behavior and Ditto API usage. - -## 1. Simplify Mutex Usage Patterns - -### Current (Rust-like) -```go -// Lines 264-271: Complex lock/unlock dance -a.mu.RLock() -if a.selectedIdx < len(a.tasks) { - task := a.tasks[a.selectedIdx] - a.mu.RUnlock() - go a.toggleTask(task.ID, !task.Done) -} else { - a.mu.RUnlock() -} -``` - -### Idiomatic Go -```go -func (a *App) getSelectedTask() (Task, bool) { - a.mu.RLock() - defer a.mu.RUnlock() - if a.selectedIdx < len(a.tasks) { - return a.tasks[a.selectedIdx], true - } - return Task{}, false -} - -// Then use it: -if task, ok := a.getSelectedTask(); ok { - go a.toggleTask(task.ID, !task.Done) -} -``` - -## 2. Simplify Error Message Handling - -### Current (Rust-like with goroutine cleanup) -```go -// Lines 389-397: Spawning goroutine to clear error -if a.errorMsg != "" { - // ... render error ... - go func() { - time.Sleep(3 * time.Second) - a.mu.Lock() - a.errorMsg = "" - a.mu.Unlock() - a.render() - }() -} -``` - -### Idiomatic Go -```go -// Use time.AfterFunc for cleaner scheduling -func (a *App) setError(msg string) { - a.mu.Lock() - a.errorMsg = msg - a.mu.Unlock() - - time.AfterFunc(3*time.Second, func() { - a.mu.Lock() - a.errorMsg = "" - a.mu.Unlock() - a.render() - }) -} -``` - -## 3. Remove Forced Initial Query Sleep - -### Current (Workaround pattern) -```go -// Lines 150-157: Sleep before initial query -go func() { - time.Sleep(200 * time.Millisecond) - result, err := d.Store().Execute("SELECT * FROM tasks WHERE deleted = false ORDER BY _id") - if err == nil && result != nil { - tasks := parseTasks(result) - app.tasksChan <- tasks - } -}() -``` - -### Idiomatic Go -```go -// Execute synchronously before starting the event loop -result, err := d.Store().Execute("SELECT * FROM tasks WHERE deleted = false ORDER BY _id") -if err == nil && result != nil { - app.tasks = parseTasks(result) -} -// Then start the observer and event loop -``` - -## 4. Simplify Type Assertion Helpers - -### Current (Rust Option-like) -```go -// Lines 545-557: Separate helper functions -func getString(m map[string]interface{}, key string) string { - if v, ok := m[key].(string); ok { - return v - } - return "" -} - -func getBool(m map[string]interface{}, key string) bool { - if v, ok := m[key].(bool); ok { - return v - } - return false -} -``` - -### Idiomatic Go (inline or generic) -```go -// Option 1: Inline the simple assertions -task := Task{ - ID: item["_id"].(string), // Assuming we know the types - Title: item["title"].(string), - Done: item["done"].(bool), - Deleted: item["deleted"].(bool), -} - -// Option 2: If defensive, use a generic helper (Go 1.18+) -func getOrDefault[T any](m map[string]interface{}, key string, defaultVal T) T { - if v, ok := m[key].(T); ok { - return v - } - return defaultVal -} -``` - -## 5. Simplify InputMode Enum - -### Current (Rust-style enum) -```go -type InputMode int -const ( - NormalMode InputMode = iota - CreateMode - EditMode -) -``` - -### Idiomatic Go -```go -// Consider using a struct with state instead -type InputState struct { - editing bool - creating bool - buffer string - editingID string -} - -// Or use simple booleans if states are mutually exclusive -type App struct { - // ... - isCreating bool - isEditing bool - // ... -} -``` - -## 6. Use Context for Cancellation - -### Current -```go -// No context-based cancellation -func (a *App) Run() { - for { - select { - case e := <-uiEvents: - // ... - } - } -} -``` - -### Idiomatic Go -```go -func (a *App) Run(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case e := <-uiEvents: - // ... - } - } -} -``` - -## 7. Simplify Task Parsing - -### Current (Pre-allocates then filters) -```go -// Lines 517-541: Allocates capacity but then filters -tasks := make([]Task, 0, result.ItemCount()) -for i := 0; i < result.ItemCount(); i++ { - // ... parse task ... - if !task.Deleted { - tasks = append(tasks, task) - } -} -``` - -### Idiomatic Go -```go -// Don't pre-allocate if filtering -var tasks []Task -for i := 0; i < result.ItemCount(); i++ { - // ... parse task ... - if !task.Deleted { - tasks = append(tasks, task) - } -} -``` - -## 8. Use Functional Options for Widget Creation - -### Current -```go -// Lines 173-179: Manual property setting -app.taskTable = widgets.NewTable() -app.taskTable.Title = " Tasks (j↓, k↑, ⏎ toggle done) " -app.taskTable.BorderStyle = ui.NewStyle(ui.ColorCyan) -app.taskTable.RowSeparator = false -``` - -### Idiomatic Go (if we could modify the widget creation) -```go -// Create a helper for cleaner initialization -func newTaskTable() *widgets.Table { - t := widgets.NewTable() - t.Title = " Tasks (j↓, k↑, ⏎ toggle done) " - t.BorderStyle = ui.NewStyle(ui.ColorCyan) - t.RowSeparator = false - t.FillRow = true - t.RowStyles[0] = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) - return t -} -``` - -## 9. Simplify Channel Usage - -### Current -```go -// Line 169: Arbitrary buffer size -tasksChan: make(chan []Task, 10), -``` - -### Idiomatic Go -```go -// Use unbuffered for synchronization or size 1 for simple cases -tasksChan: make(chan []Task, 1), // Latest update wins -``` - -## 10. Event Handler Map - -### Current (Large switch statement) -```go -func (a *App) handleNormalMode(e ui.Event) { - switch e.ID { - case "j", "": - // ... - case "k", "": - // ... - } -} -``` - -### Idiomatic Go -```go -// Define handlers as a map -var normalModeHandlers = map[string]func(*App){ - "j": (*App).moveDown, - "": (*App).moveDown, - "k": (*App).moveUp, - "": (*App).moveUp, - // ... -} - -func (a *App) handleNormalMode(e ui.Event) { - if handler, ok := normalModeHandlers[e.ID]; ok { - handler(a) - } -} -``` - -## 11. Embed Mutex for Cleaner Code - -### Current -```go -type App struct { - // ... - mu sync.RWMutex -} - -// Usage: -a.mu.Lock() -defer a.mu.Unlock() -``` - -### Idiomatic Go (for some cases) -```go -type App struct { - // ... - sync.RWMutex // Embedded -} - -// Usage: -a.Lock() -defer a.Unlock() -``` - -## 12. Use sync.Once for Initialization - -### Current -```go -// Complex initialization spread across main and NewApp -``` - -### Idiomatic Go -```go -type App struct { - initOnce sync.Once - // ... -} - -func (a *App) ensureInitialized() { - a.initOnce.Do(func() { - // One-time initialization - }) -} -``` - -## Summary of Key Changes - -1. **Reduce mutex complexity** - Use helper methods with defer -2. **Simplify error handling** - Use time.AfterFunc instead of goroutines with sleep -3. **Remove initialization sleeps** - Do synchronous setup before async operations -4. **Inline simple type assertions** - Don't over-abstract -5. **Use context for cancellation** - Standard Go pattern for lifecycle management -6. **Simplify state management** - Consider alternatives to enum-like patterns -7. **Optimize slice allocation** - Don't pre-allocate when filtering -8. **Consider handler maps** - For large switch statements -9. **Use appropriate channel sizes** - Not arbitrary buffers - -These changes would make the code more idiomatic Go while maintaining identical functionality and Ditto API usage. \ No newline at end of file From 75c1e173114f400ce69608cfdacf1159e17fbe07 Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Wed, 5 Nov 2025 17:32:42 -0500 Subject: [PATCH 32/38] use downloaded libraries and Go module --- go-tui/.gitignore | 1 + go-tui/Makefile | 26 ++++++++++++++++---------- go-tui/go.mod | 4 ++-- go-tui/go.sum | 8 -------- go-tui/main.go | 2 +- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/go-tui/.gitignore b/go-tui/.gitignore index 0420c81fa..624207980 100644 --- a/go-tui/.gitignore +++ b/go-tui/.gitignore @@ -2,3 +2,4 @@ ditto-tasks-termui go-tui *.log *.out +go-sdk/ diff --git a/go-tui/Makefile b/go-tui/Makefile index d3a6c17f4..08e8b7b1c 100644 --- a/go-tui/Makefile +++ b/go-tui/Makefile @@ -23,14 +23,19 @@ ARCH := $(shell uname -m) # Determine Ditto SDK platform string ifeq ($(PLATFORM),linux) - DITTO_PLATFORM = go-linux-$(ARCH) + DITTO_PLATFORM = linux-$(ARCH) else ifeq ($(PLATFORM),darwin) - DITTO_PLATFORM = go-macos-aarch64 + DITTO_PLATFORM = macos-aarch64 + + # avoid version mismatch warnings when linking + export MACOSX_DEPLOYMENT_TARGET := 11.0 + export CGO_CFLAGS := -mmacosx-version-min=11.0 + export CGO_LDFLAGS := -mmacosx-version-min=11.0 else $(error Unsupported platform: $(PLATFORM)) endif -DITTO_SDK_URL = https://software.ditto.live/$(DITTO_PLATFORM)/Ditto/$(DITTO_SDK_VERSION)/dist/Ditto.tar.gz +DITTO_SDK_URL = https://software.ditto.live/go-$(DITTO_PLATFORM)/Ditto/$(DITTO_SDK_VERSION)/libs/libdittoffi-$(DITTO_PLATFORM).tar.gz # Build the application .PHONY: build @@ -38,12 +43,13 @@ build: go-sdk ditto-tasks-termui ditto-tasks-termui: @echo "Building ditto-tasks-termui..." - $(GO) build -o ditto-tasks-termui + $(GO) mod tidy + $(GO) build -o ditto-tasks-termui -ldflags='-extldflags "-L./go-sdk"' .PHONY: go-sdk go-sdk: ## Downloads and installs the Ditto Go SDK library to go-sdk directory - @if [ ! -f go-sdk/libdittoffi.so ] && [ ! -f go-sdk/libdittoffi.dylib ] ; then \ - @echo "📥 Downloading Ditto Go SDK v$(DITTO_SDK_VERSION) for $(DITTO_PLATFORM)..." + @ if [ ! -f go-sdk/libdittoffi.so ] && [ ! -f go-sdk/libdittoffi.dylib ] ; then \ + echo "📥 Downloading Ditto Go SDK v$(DITTO_SDK_VERSION) for $(DITTO_PLATFORM) $(DITTO_SDK_URL)..."; \ mkdir -p go-sdk; \ if curl -L -f $(DITTO_SDK_URL) | tar xz --strip-components=0 -C go-sdk/; then \ echo "✅ Ditto Go SDK v$(DITTO_SDK_VERSION) installed successfully"; \ @@ -55,20 +61,20 @@ go-sdk: ## Downloads and installs the Ditto Go SDK library to go-sdk directory fi # Run the application (built binary) -.PHONY: BUILD_TYPE +.PHONY: build run: build @echo "Running ditto-tasks-termui..." - LD_LIBRARY_PATH="$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$(pwd)/go-sdk" ./ditto-tasks-termui 2>/dev/null + LD_LIBRARY_PATH="$$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$$(pwd)/go-sdk" ./ditto-tasks-termui 2>/dev/null # Run directly with Go .PHONY: run-go run-go: @echo "Running ditto tasks-termui with go run..." - LD_LIBRARY_PATH="$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$(pwd)/go-sdk" $(GO) run main.go 2>/dev/null + LD_LIBRARY_PATH="$$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$$(pwd)/go-sdk" $(GO) run main.go 2>/dev/null # Clean build artifacts .PHONY: clean clean: @echo "Cleaning ditto-tasks-termui and build artifacts..." rm -f ditto-tasks-termui - rm -f go-sdk + rm -rf go-sdk diff --git a/go-tui/go.mod b/go-tui/go.mod index 8d6aa95d1..f3cc86d87 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -1,9 +1,9 @@ module github.com/getditto/quickstart/go-tui/ditto-tasks-termui -go 1.23 +go 1.24.0 require ( - github.com/getditto/ditto-go-sdk v5.0.0-go-preview.1 + github.com/getditto/ditto-go-sdk/v5 v5.0.0 github.com/gizak/termui/v3 v3.1.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 diff --git a/go-tui/go.sum b/go-tui/go.sum index f2964de1f..2748ec15d 100644 --- a/go-tui/go.sum +++ b/go-tui/go.sum @@ -1,5 +1,3 @@ -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/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= @@ -15,18 +13,12 @@ github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzC github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= -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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-tui/main.go b/go-tui/main.go index 947c51732..2d68bab28 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/getditto/ditto-go-sdk/ditto" + "github.com/getditto/ditto-go-sdk/v5/ditto" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" "github.com/google/uuid" From 265a72fe858f673d12abc50f26c78b1aed1ff107 Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Thu, 6 Nov 2025 10:40:02 -0500 Subject: [PATCH 33/38] Update SDK version to 5.0.0-experimental-go-publish.10 --- go-tui/Makefile | 7 ++++--- go-tui/go.mod | 19 +++++++++++-------- go-tui/go.sum | 30 +++++++++++++++++------------- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/go-tui/Makefile b/go-tui/Makefile index 08e8b7b1c..6272a3024 100644 --- a/go-tui/Makefile +++ b/go-tui/Makefile @@ -17,7 +17,8 @@ help: GO=go # Ditto SDK version and platform detection -DITTO_SDK_VERSION ?= 5.0.0-go-preview.1 +#DITTO_SDK_VERSION ?= 5.0.0-go-preview.1 +DITTO_SDK_VERSION ?= 5.0.0-experimental-go-publish.10 PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]') ARCH := $(shell uname -m) @@ -35,13 +36,13 @@ else $(error Unsupported platform: $(PLATFORM)) endif -DITTO_SDK_URL = https://software.ditto.live/go-$(DITTO_PLATFORM)/Ditto/$(DITTO_SDK_VERSION)/libs/libdittoffi-$(DITTO_PLATFORM).tar.gz +DITTO_SDK_URL = "https://software.ditto.live/go-$(DITTO_PLATFORM)/Ditto/$(DITTO_SDK_VERSION)/libs/libdittoffi-$(DITTO_PLATFORM).tar.gz" # Build the application .PHONY: build build: go-sdk ditto-tasks-termui -ditto-tasks-termui: +ditto-tasks-termui: main.go redirect_unix.go redirect_windows.go widgets.go Makefile go.mod go.sum @echo "Building ditto-tasks-termui..." $(GO) mod tidy $(GO) build -o ditto-tasks-termui -ldflags='-extldflags "-L./go-sdk"' diff --git a/go-tui/go.mod b/go-tui/go.mod index f3cc86d87..db11e3232 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -3,19 +3,22 @@ module github.com/getditto/quickstart/go-tui/ditto-tasks-termui go 1.24.0 require ( - github.com/getditto/ditto-go-sdk/v5 v5.0.0 + github.com/getditto/ditto-go-sdk/v5 v5.0.0-experimental-go-publish.10 github.com/gizak/termui/v3 v3.1.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - golang.org/x/sys v0.36.0 - golang.org/x/term v0.35.0 + golang.org/x/sys v0.37.0 + golang.org/x/term v0.36.0 ) +replace github.com/getditto/ditto-go-sdk/v5 => /Users/kristopherjohnson/getditto/ditto-go-sdk + require ( - github.com/fxamacker/cbor/v2 v2.5.0 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect - github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect - github.com/rivo/uniseg v0.4.7 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect github.com/x448/float16 v0.8.4 // indirect ) diff --git a/go-tui/go.sum b/go-tui/go.sum index 2748ec15d..280fbc6f1 100644 --- a/go-tui/go.sum +++ b/go-tui/go.sum @@ -1,5 +1,9 @@ -github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= -github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -7,18 +11,18 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= From 85e06b9f5400928c5991ff6baae9f8209c4ef07c Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Thu, 6 Nov 2025 10:42:18 -0500 Subject: [PATCH 34/38] remove `replace` directive --- go-tui/go.mod | 2 -- go-tui/go.sum | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go-tui/go.mod b/go-tui/go.mod index db11e3232..2adfc3e06 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -11,8 +11,6 @@ require ( golang.org/x/term v0.36.0 ) -replace github.com/getditto/ditto-go-sdk/v5 => /Users/kristopherjohnson/getditto/ditto-go-sdk - require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect diff --git a/go-tui/go.sum b/go-tui/go.sum index 280fbc6f1..f4264dd0d 100644 --- a/go-tui/go.sum +++ b/go-tui/go.sum @@ -4,6 +4,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuh github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getditto/ditto-go-sdk/v5 v5.0.0-experimental-go-publish.10 h1:yXmgeTUVdVY/pBBW35cFBWA7xFEr2aPQ4LjatIRdid8= +github.com/getditto/ditto-go-sdk/v5 v5.0.0-experimental-go-publish.10/go.mod h1:LFVfgkbjAENnhaxjd4rUfOUVOH7BC3yEuKCo1Ps/Kbg= github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= From f4e9bd7192f182bdae9e44caa58180f5d6e6186b Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Thu, 6 Nov 2025 17:13:44 -0500 Subject: [PATCH 35/38] Update go-tui/Makefile Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- go-tui/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-tui/Makefile b/go-tui/Makefile index 6272a3024..01e9f0aad 100644 --- a/go-tui/Makefile +++ b/go-tui/Makefile @@ -62,7 +62,7 @@ go-sdk: ## Downloads and installs the Ditto Go SDK library to go-sdk directory fi # Run the application (built binary) -.PHONY: build +.PHONY: run run: build @echo "Running ditto-tasks-termui..." LD_LIBRARY_PATH="$$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$$(pwd)/go-sdk" ./ditto-tasks-termui 2>/dev/null From e818f7073bc4330811253c6d0b5ffdf2da3b3798 Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Mon, 17 Nov 2025 16:24:19 -0500 Subject: [PATCH 36/38] update to 5.0.0-go-preview.3 --- go-tui/Makefile | 5 ++--- go-tui/go.mod | 6 +++--- go-tui/go.sum | 12 ++++++------ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/go-tui/Makefile b/go-tui/Makefile index 6272a3024..98e7e0f4d 100644 --- a/go-tui/Makefile +++ b/go-tui/Makefile @@ -17,8 +17,7 @@ help: GO=go # Ditto SDK version and platform detection -#DITTO_SDK_VERSION ?= 5.0.0-go-preview.1 -DITTO_SDK_VERSION ?= 5.0.0-experimental-go-publish.10 +DITTO_SDK_VERSION ?= 5.0.0-go-preview.3 PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]') ARCH := $(shell uname -m) @@ -36,7 +35,7 @@ else $(error Unsupported platform: $(PLATFORM)) endif -DITTO_SDK_URL = "https://software.ditto.live/go-$(DITTO_PLATFORM)/Ditto/$(DITTO_SDK_VERSION)/libs/libdittoffi-$(DITTO_PLATFORM).tar.gz" +DITTO_SDK_URL = "https://software.ditto.live/go/Ditto/$(DITTO_SDK_VERSION)/libs/libdittoffi-$(DITTO_PLATFORM).tar.gz" # Build the application .PHONY: build diff --git a/go-tui/go.mod b/go-tui/go.mod index 2adfc3e06..16c3152eb 100644 --- a/go-tui/go.mod +++ b/go-tui/go.mod @@ -3,12 +3,12 @@ module github.com/getditto/quickstart/go-tui/ditto-tasks-termui go 1.24.0 require ( - github.com/getditto/ditto-go-sdk/v5 v5.0.0-experimental-go-publish.10 + github.com/getditto/ditto-go-sdk/v5 v5.0.0-go-preview.3 github.com/gizak/termui/v3 v3.1.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - golang.org/x/sys v0.37.0 - golang.org/x/term v0.36.0 + golang.org/x/sys v0.38.0 + golang.org/x/term v0.37.0 ) require ( diff --git a/go-tui/go.sum b/go-tui/go.sum index f4264dd0d..ed0dcd7b4 100644 --- a/go-tui/go.sum +++ b/go-tui/go.sum @@ -4,8 +4,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuh github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/getditto/ditto-go-sdk/v5 v5.0.0-experimental-go-publish.10 h1:yXmgeTUVdVY/pBBW35cFBWA7xFEr2aPQ4LjatIRdid8= -github.com/getditto/ditto-go-sdk/v5 v5.0.0-experimental-go-publish.10/go.mod h1:LFVfgkbjAENnhaxjd4rUfOUVOH7BC3yEuKCo1Ps/Kbg= +github.com/getditto/ditto-go-sdk/v5 v5.0.0-go-preview.3 h1:u3ocpt4YNd9WPu4j4ZfYs/sZ4o6Fl4wuthUrzVZr/pw= +github.com/getditto/ditto-go-sdk/v5 v5.0.0-go-preview.3/go.mod h1:LFVfgkbjAENnhaxjd4rUfOUVOH7BC3yEuKCo1Ps/Kbg= github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -24,7 +24,7 @@ github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= From e22053fefae87de62c5c73e4b0ded550e2a0326a Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Mon, 17 Nov 2025 16:38:03 -0500 Subject: [PATCH 37/38] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- go-tui/redirect_unix.go | 1 - 1 file changed, 1 deletion(-) diff --git a/go-tui/redirect_unix.go b/go-tui/redirect_unix.go index 7f5a8fcbd..4c0993e51 100644 --- a/go-tui/redirect_unix.go +++ b/go-tui/redirect_unix.go @@ -26,7 +26,6 @@ func redirectStderr() { log.Printf("Failed to open %s: %v", os.DevNull, err) return } - defer devNull.Close() // Redirect stderr to /dev/null if err := unix.Dup2(int(devNull.Fd()), int(os.Stderr.Fd())); err != nil { From 1bb06da3fe8d17a8b802b881356fc7ff7d7c47e8 Mon Sep 17 00:00:00 2001 From: Kristopher Johnson Date: Mon, 17 Nov 2025 17:42:06 -0500 Subject: [PATCH 38/38] Enable the `ditto.SetMinimumLogLevel()` line which was causing segfaults in older builds --- go-tui/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-tui/main.go b/go-tui/main.go index 2d68bab28..f4fbdb1a0 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -75,7 +75,7 @@ func main() { } // Set Ditto log level to Error to suppress most logs - // ditto.SetMinimumLogLevel(ditto.LogLevelError) // Commented out - causing segfault + ditto.SetMinimumLogLevel(ditto.LogLevelError) // Load environment variables if err := loadEnv(); err != nil {