fix(review): resolve remaining CodeRabbit findings
Harden floating-pane rollback behavior under navigation errors, make floating profile validation deterministic, and preserve callback logging context in omnibox navigation. Improve installer prerelease parsing reliability with jq-first extraction and documented awk fallback limits.
- commit
- e83c735b8f99eb08972165801af6e82282a7773a
- author
- brice · · 2026-02-09 14:59 UTC
- parent
- 921cc59
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +145 -12
M install.sh +37 -3
| @@ -42,12 +42,34 @@ TARBALL="dumber_linux_x86_64.tar.gz" | |||
| 42 | 42 | # Determine version to download | |
| 43 | 43 | if [ "$VERSION" = "latest" ] && [ -n "$DUMBER_PRERELEASE" ]; then | |
| 44 | 44 | echo "Finding latest pre-release..." | |
| 45 | - | RELEASE_DATA=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" 2>/dev/null || echo "") | |
| 45 | + | RELEASE_DATA=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100" 2>/dev/null || echo "") | |
| 46 | 46 | if [ -z "$RELEASE_DATA" ]; then | |
| 47 | 47 | echo "Error: Failed to fetch release information from GitHub API" | |
| 48 | 48 | exit 1 | |
| 49 | 49 | fi | |
| 50 | - | VERSION=$(echo "$RELEASE_DATA" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/') | |
| 50 | + | # Prefer jq for reliable JSON parsing of RELEASE_DATA into VERSION. | |
| 51 | + | # The awk branch is a portability fallback that assumes prerelease and | |
| 52 | + | # tag_name appear in the same '{' fragment; JSON strings containing '{' | |
| 53 | + | # or API shape changes can break this extraction. | |
| 54 | + | if command -v jq >/dev/null 2>&1; then | |
| 55 | + | VERSION=$(echo "$RELEASE_DATA" | jq -r '.[] | select(.prerelease == true) | .tag_name' | head -1) | |
| 56 | + | else | |
| 57 | + | VERSION=$(echo "$RELEASE_DATA" | awk ' | |
| 58 | + | BEGIN { RS = "{" } | |
| 59 | + | /"prerelease"[[:space:]]*:[[:space:]]*true/ { | |
| 60 | + | tag = "" | |
| 61 | + | if (match($0, /"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"/)) { | |
| 62 | + | tag = substr($0, RSTART, RLENGTH) | |
| 63 | + | sub(/.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", tag) | |
| 64 | + | sub(/".*/, "", tag) | |
| 65 | + | } | |
| 66 | + | if (tag != "") { | |
| 67 | + | print tag | |
| 68 | + | exit | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ') | |
| 72 | + | fi | |
| 51 | 73 | if [ -z "$VERSION" ]; then | |
| 52 | 74 | echo "Error: Could not determine latest pre-release version" | |
| 53 | 75 | exit 1 | |
| @@ -60,7 +82,19 @@ elif [ "$VERSION" = "latest" ]; then | |||
| 60 | 82 | echo "Error: Failed to fetch latest release information from GitHub API" | |
| 61 | 83 | exit 1 | |
| 62 | 84 | fi | |
| 63 | - | VERSION=$(echo "$RELEASE_DATA" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/') | |
| 85 | + | if command -v jq >/dev/null 2>&1; then | |
| 86 | + | VERSION=$(echo "$RELEASE_DATA" | jq -r '.tag_name') | |
| 87 | + | else | |
| 88 | + | VERSION=$(echo "$RELEASE_DATA" | awk ' | |
| 89 | + | /"tag_name"[[:space:]]*:/ { | |
| 90 | + | tag = $0 | |
| 91 | + | sub(/.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", tag) | |
| 92 | + | sub(/".*/, "", tag) | |
| 93 | + | print tag | |
| 94 | + | exit | |
| 95 | + | } | |
| 96 | + | ') | |
| 97 | + | fi | |
| 64 | 98 | if [ -z "$VERSION" ]; then | |
| 65 | 99 | echo "Error: Could not determine latest stable version" | |
| 66 | 100 | exit 1 | |
| @@ -42,12 +42,34 @@ TARBALL="dumber_linux_x86_64.tar.gz" | |||||
| 42 | # Determine version to download | 42 | # Determine version to download | ||
| 43 | if [ "$VERSION" = "latest" ] && [ -n "$DUMBER_PRERELEASE" ]; then | 43 | if [ "$VERSION" = "latest" ] && [ -n "$DUMBER_PRERELEASE" ]; then | ||
| 44 | echo "Finding latest pre-release..." | 44 | echo "Finding latest pre-release..." | ||
| 45 | - | RELEASE_DATA=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" 2>/dev/null || echo "") | 45 | + | RELEASE_DATA=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100" 2>/dev/null || echo "") |
| 46 | if [ -z "$RELEASE_DATA" ]; then | 46 | if [ -z "$RELEASE_DATA" ]; then | ||
| 47 | echo "Error: Failed to fetch release information from GitHub API" | 47 | echo "Error: Failed to fetch release information from GitHub API" | ||
| 48 | exit 1 | 48 | exit 1 | ||
| 49 | fi | 49 | fi | ||
| 50 | - | VERSION=$(echo "$RELEASE_DATA" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/') | 50 | + | # Prefer jq for reliable JSON parsing of RELEASE_DATA into VERSION. |
| 51 | + | # The awk branch is a portability fallback that assumes prerelease and | |||
| 52 | + | # tag_name appear in the same '{' fragment; JSON strings containing '{' | |||
| 53 | + | # or API shape changes can break this extraction. | |||
| 54 | + | if command -v jq >/dev/null 2>&1; then | |||
| 55 | + | VERSION=$(echo "$RELEASE_DATA" | jq -r '.[] | select(.prerelease == true) | .tag_name' | head -1) | |||
| 56 | + | else | |||
| 57 | + | VERSION=$(echo "$RELEASE_DATA" | awk ' | |||
| 58 | + | BEGIN { RS = "{" } | |||
| 59 | + | /"prerelease"[[:space:]]*:[[:space:]]*true/ { | |||
| 60 | + | tag = "" | |||
| 61 | + | if (match($0, /"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"/)) { | |||
| 62 | + | tag = substr($0, RSTART, RLENGTH) | |||
| 63 | + | sub(/.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", tag) | |||
| 64 | + | sub(/".*/, "", tag) | |||
| 65 | + | } | |||
| 66 | + | if (tag != "") { | |||
| 67 | + | print tag | |||
| 68 | + | exit | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ') | |||
| 72 | + | fi | |||
| 51 | if [ -z "$VERSION" ]; then | 73 | if [ -z "$VERSION" ]; then | ||
| 52 | echo "Error: Could not determine latest pre-release version" | 74 | echo "Error: Could not determine latest pre-release version" | ||
| 53 | exit 1 | 75 | exit 1 | ||
| @@ -60,7 +82,19 @@ elif [ "$VERSION" = "latest" ]; then | |||||
| 60 | echo "Error: Failed to fetch latest release information from GitHub API" | 82 | echo "Error: Failed to fetch latest release information from GitHub API" | ||
| 61 | exit 1 | 83 | exit 1 | ||
| 62 | fi | 84 | fi | ||
| 63 | - | VERSION=$(echo "$RELEASE_DATA" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/') | 85 | + | if command -v jq >/dev/null 2>&1; then |
| 86 | + | VERSION=$(echo "$RELEASE_DATA" | jq -r '.tag_name') | |||
| 87 | + | else | |||
| 88 | + | VERSION=$(echo "$RELEASE_DATA" | awk ' | |||
| 89 | + | /"tag_name"[[:space:]]*:/ { | |||
| 90 | + | tag = $0 | |||
| 91 | + | sub(/.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", tag) | |||
| 92 | + | sub(/".*/, "", tag) | |||
| 93 | + | print tag | |||
| 94 | + | exit | |||
| 95 | + | } | |||
| 96 | + | ') | |||
| 97 | + | fi | |||
| 64 | if [ -z "$VERSION" ]; then | 98 | if [ -z "$VERSION" ]; then | ||
| 65 | echo "Error: Could not determine latest stable version" | 99 | echo "Error: Could not determine latest stable version" | ||
| 66 | exit 1 | 100 | exit 1 | ||
M internal/infrastructure/config/migrate.go +3 -2
| @@ -173,7 +173,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||
| 173 | 173 | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | |
| 174 | 174 | var deprecated []string | |
| 175 | 175 | for userKey := range userKeys { | |
| 176 | - | if userKey == "database.path" { | |
| 176 | + | if userKey == databasePathKey { | |
| 177 | 177 | continue | |
| 178 | 178 | } | |
| 179 | 179 | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | |
| @@ -264,6 +264,7 @@ const ( | |||
| 264 | 264 | configFormatTOML = "toml" | |
| 265 | 265 | configFormatYAML = "yaml" | |
| 266 | 266 | configFormatJSON = "json" | |
| 267 | + | databasePathKey = "database.path" | |
| 267 | 268 | workspaceShortcutsActionsKey = "workspace.shortcuts.actions" | |
| 268 | 269 | ) | |
| 269 | 270 | ||
| @@ -629,7 +630,7 @@ func (m *Migrator) getAllDefaultKeys() []string { | |||
| 629 | 630 | filtered := make([]string, 0, len(keys)) | |
| 630 | 631 | for _, key := range keys { | |
| 631 | 632 | // Skip database.path as it's set dynamically | |
| 632 | - | if key == "database.path" { | |
| 633 | + | if key == databasePathKey { | |
| 633 | 634 | continue | |
| 634 | 635 | } | |
| 635 | 636 | filtered = append(filtered, key) | |
| @@ -173,7 +173,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||||
| 173 | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | 173 | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | ||
| 174 | var deprecated []string | 174 | var deprecated []string | ||
| 175 | for userKey := range userKeys { | 175 | for userKey := range userKeys { | ||
| 176 | - | if userKey == "database.path" { | 176 | + | if userKey == databasePathKey { |
| 177 | continue | 177 | continue | ||
| 178 | } | 178 | } | ||
| 179 | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | 179 | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | ||
| @@ -264,6 +264,7 @@ const ( | |||||
| 264 | configFormatTOML = "toml" | 264 | configFormatTOML = "toml" | ||
| 265 | configFormatYAML = "yaml" | 265 | configFormatYAML = "yaml" | ||
| 266 | configFormatJSON = "json" | 266 | configFormatJSON = "json" | ||
| 267 | + | databasePathKey = "database.path" | |||
| 267 | workspaceShortcutsActionsKey = "workspace.shortcuts.actions" | 268 | workspaceShortcutsActionsKey = "workspace.shortcuts.actions" | ||
| 268 | ) | 269 | ) | ||
| 269 | 270 | ||||
| @@ -629,7 +630,7 @@ func (m *Migrator) getAllDefaultKeys() []string { | |||||
| 629 | filtered := make([]string, 0, len(keys)) | 630 | filtered := make([]string, 0, len(keys)) | ||
| 630 | for _, key := range keys { | 631 | for _, key := range keys { | ||
| 631 | // Skip database.path as it's set dynamically | 632 | // Skip database.path as it's set dynamically | ||
| 632 | - | if key == "database.path" { | 633 | + | if key == databasePathKey { |
| 633 | continue | 634 | continue | ||
| 634 | } | 635 | } | ||
| 635 | filtered = append(filtered, key) | 636 | filtered = append(filtered, key) | ||
M internal/infrastructure/config/validation.go +9 -1
| @@ -3,6 +3,7 @@ package config | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "net/url" | |
| 6 | + | "sort" | |
| 6 | 7 | "strings" | |
| 7 | 8 | ||
| 8 | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| @@ -237,7 +238,14 @@ func validateFloatingPane(config *Config) []string { | |||
| 237 | 238 | validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]") | |
| 238 | 239 | } | |
| 239 | 240 | ||
| 240 | - | for profileName, profile := range config.Workspace.FloatingPane.Profiles { | |
| 241 | + | profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles)) | |
| 242 | + | for profileName := range config.Workspace.FloatingPane.Profiles { | |
| 243 | + | profileNames = append(profileNames, profileName) | |
| 244 | + | } | |
| 245 | + | sort.Strings(profileNames) | |
| 246 | + | ||
| 247 | + | for _, profileName := range profileNames { | |
| 248 | + | profile := config.Workspace.FloatingPane.Profiles[profileName] | |
| 241 | 249 | if strings.TrimSpace(profile.URL) == "" { | |
| 242 | 250 | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 243 | 251 | "workspace.floating_pane.profiles.%s.url is required", | |
| @@ -3,6 +3,7 @@ package config | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "net/url" | 5 | "net/url" | ||
| 6 | + | "sort" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | 8 | ||||
| 8 | domainurl "github.com/bnema/dumber/internal/domain/url" | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| @@ -237,7 +238,14 @@ func validateFloatingPane(config *Config) []string { | |||||
| 237 | validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]") | 238 | validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]") | ||
| 238 | } | 239 | } | ||
| 239 | 240 | ||||
| 240 | - | for profileName, profile := range config.Workspace.FloatingPane.Profiles { | 241 | + | profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles)) |
| 242 | + | for profileName := range config.Workspace.FloatingPane.Profiles { | |||
| 243 | + | profileNames = append(profileNames, profileName) | |||
| 244 | + | } | |||
| 245 | + | sort.Strings(profileNames) | |||
| 246 | + | ||||
| 247 | + | for _, profileName := range profileNames { | |||
| 248 | + | profile := config.Workspace.FloatingPane.Profiles[profileName] | |||
| 241 | if strings.TrimSpace(profile.URL) == "" { | 249 | if strings.TrimSpace(profile.URL) == "" { | ||
| 242 | validationErrors = append(validationErrors, fmt.Sprintf( | 250 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 243 | "workspace.floating_pane.profiles.%s.url is required", | 251 | "workspace.floating_pane.profiles.%s.url is required", | ||
M internal/ui/component/floating_pane.go +30 -3
| @@ -33,6 +33,7 @@ type FloatingPane struct { | |||
| 33 | 33 | omniboxVisible bool | |
| 34 | 34 | sessionStarted bool | |
| 35 | 35 | currentURL string | |
| 36 | + | stateVersion uint64 | |
| 36 | 37 | calculatedWidth int | |
| 37 | 38 | calculatedHeight int | |
| 38 | 39 | } | |
| @@ -67,9 +68,12 @@ func NewFloatingPane(parent layout.OverlayWidget, opts FloatingPaneOptions) *Flo | |||
| 67 | 68 | // ShowToggle toggles pane visibility; first open initializes a blank session. | |
| 68 | 69 | func (fp *FloatingPane) ShowToggle(ctx context.Context) error { | |
| 69 | 70 | fp.mu.Lock() | |
| 71 | + | previousVisible := fp.visible | |
| 72 | + | previousOmniboxVisible := fp.omniboxVisible | |
| 70 | 73 | if fp.visible { | |
| 71 | 74 | fp.visible = false | |
| 72 | 75 | fp.omniboxVisible = false | |
| 76 | + | fp.stateVersion++ | |
| 73 | 77 | fp.mu.Unlock() | |
| 74 | 78 | return nil | |
| 75 | 79 | } | |
| @@ -78,6 +82,8 @@ func (fp *FloatingPane) ShowToggle(ctx context.Context) error { | |||
| 78 | 82 | showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL | |
| 79 | 83 | fp.visible = true | |
| 80 | 84 | fp.omniboxVisible = showOmnibox | |
| 85 | + | fp.stateVersion++ | |
| 86 | + | rollbackVersion := fp.stateVersion | |
| 81 | 87 | // Release lock before Navigate: callbacks may block and must not run while | |
| 82 | 88 | // holding fp.mu. On error, rollback below restores visibility state. | |
| 83 | 89 | fp.mu.Unlock() | |
| @@ -85,8 +91,11 @@ func (fp *FloatingPane) ShowToggle(ctx context.Context) error { | |||
| 85 | 91 | if navigateToBlank { | |
| 86 | 92 | if err := fp.Navigate(ctx, defaultFloatingPaneURL); err != nil { | |
| 87 | 93 | fp.mu.Lock() | |
| 88 | - | fp.visible = false | |
| 89 | - | fp.omniboxVisible = false | |
| 94 | + | if fp.stateVersion == rollbackVersion { | |
| 95 | + | fp.visible = previousVisible | |
| 96 | + | fp.omniboxVisible = previousOmniboxVisible | |
| 97 | + | fp.stateVersion++ | |
| 98 | + | } | |
| 90 | 99 | fp.mu.Unlock() | |
| 91 | 100 | return err | |
| 92 | 101 | } | |
| @@ -102,6 +111,7 @@ func (fp *FloatingPane) Show() { | |||
| 102 | 111 | defer fp.mu.Unlock() | |
| 103 | 112 | fp.visible = true | |
| 104 | 113 | fp.omniboxVisible = false | |
| 114 | + | fp.stateVersion++ | |
| 105 | 115 | } | |
| 106 | 116 | ||
| 107 | 117 | // SessionStarted reports whether the floating pane has navigated at least once. | |
| @@ -119,13 +129,26 @@ func (fp *FloatingPane) ShowURL(ctx context.Context, url string) error { | |||
| 119 | 129 | } | |
| 120 | 130 | ||
| 121 | 131 | fp.mu.Lock() | |
| 132 | + | previousVisible := fp.visible | |
| 133 | + | previousOmniboxVisible := fp.omniboxVisible | |
| 122 | 134 | fp.visible = true | |
| 123 | 135 | fp.omniboxVisible = false | |
| 124 | 136 | shouldNavigate := !fp.sessionStarted || fp.currentURL != url | |
| 137 | + | fp.stateVersion++ | |
| 138 | + | rollbackVersion := fp.stateVersion | |
| 125 | 139 | fp.mu.Unlock() | |
| 126 | 140 | ||
| 127 | 141 | if shouldNavigate { | |
| 128 | - | return fp.Navigate(ctx, url) | |
| 142 | + | if err := fp.Navigate(ctx, url); err != nil { | |
| 143 | + | fp.mu.Lock() | |
| 144 | + | if fp.stateVersion == rollbackVersion { | |
| 145 | + | fp.visible = previousVisible | |
| 146 | + | fp.omniboxVisible = previousOmniboxVisible | |
| 147 | + | fp.stateVersion++ | |
| 148 | + | } | |
| 149 | + | fp.mu.Unlock() | |
| 150 | + | return err | |
| 151 | + | } | |
| 129 | 152 | } | |
| 130 | 153 | ||
| 131 | 154 | return nil | |
| @@ -137,6 +160,7 @@ func (fp *FloatingPane) Hide(_ context.Context) { | |||
| 137 | 160 | defer fp.mu.Unlock() | |
| 138 | 161 | fp.visible = false | |
| 139 | 162 | fp.omniboxVisible = false | |
| 163 | + | fp.stateVersion++ | |
| 140 | 164 | } | |
| 141 | 165 | ||
| 142 | 166 | // Navigate loads a URL into the floating pane's persistent session. | |
| @@ -159,6 +183,7 @@ func (fp *FloatingPane) Navigate(ctx context.Context, url string) error { | |||
| 159 | 183 | fp.mu.Lock() | |
| 160 | 184 | fp.currentURL = url | |
| 161 | 185 | fp.sessionStarted = true | |
| 186 | + | fp.stateVersion++ | |
| 162 | 187 | fp.mu.Unlock() | |
| 163 | 188 | ||
| 164 | 189 | return nil | |
| @@ -217,6 +242,7 @@ func (fp *FloatingPane) IsOmniboxVisible() bool { | |||
| 217 | 242 | func (fp *FloatingPane) SetOmniboxVisible(visible bool) { | |
| 218 | 243 | fp.mu.Lock() | |
| 219 | 244 | fp.omniboxVisible = visible | |
| 245 | + | fp.stateVersion++ | |
| 220 | 246 | fp.mu.Unlock() | |
| 221 | 247 | } | |
| 222 | 248 | ||
| @@ -230,5 +256,6 @@ func (fp *FloatingPane) RecordLoadedURL(url string) { | |||
| 230 | 256 | fp.mu.Lock() | |
| 231 | 257 | fp.currentURL = url | |
| 232 | 258 | fp.sessionStarted = true | |
| 259 | + | fp.stateVersion++ | |
| 233 | 260 | fp.mu.Unlock() | |
| 234 | 261 | } | |
| @@ -33,6 +33,7 @@ type FloatingPane struct { | |||||
| 33 | omniboxVisible bool | 33 | omniboxVisible bool | ||
| 34 | sessionStarted bool | 34 | sessionStarted bool | ||
| 35 | currentURL string | 35 | currentURL string | ||
| 36 | + | stateVersion uint64 | |||
| 36 | calculatedWidth int | 37 | calculatedWidth int | ||
| 37 | calculatedHeight int | 38 | calculatedHeight int | ||
| 38 | } | 39 | } | ||
| @@ -67,9 +68,12 @@ func NewFloatingPane(parent layout.OverlayWidget, opts FloatingPaneOptions) *Flo | |||||
| 67 | // ShowToggle toggles pane visibility; first open initializes a blank session. | 68 | // ShowToggle toggles pane visibility; first open initializes a blank session. | ||
| 68 | func (fp *FloatingPane) ShowToggle(ctx context.Context) error { | 69 | func (fp *FloatingPane) ShowToggle(ctx context.Context) error { | ||
| 69 | fp.mu.Lock() | 70 | fp.mu.Lock() | ||
| 71 | + | previousVisible := fp.visible | |||
| 72 | + | previousOmniboxVisible := fp.omniboxVisible | |||
| 70 | if fp.visible { | 73 | if fp.visible { | ||
| 71 | fp.visible = false | 74 | fp.visible = false | ||
| 72 | fp.omniboxVisible = false | 75 | fp.omniboxVisible = false | ||
| 76 | + | fp.stateVersion++ | |||
| 73 | fp.mu.Unlock() | 77 | fp.mu.Unlock() | ||
| 74 | return nil | 78 | return nil | ||
| 75 | } | 79 | } | ||
| @@ -78,6 +82,8 @@ func (fp *FloatingPane) ShowToggle(ctx context.Context) error { | |||||
| 78 | showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL | 82 | showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL | ||
| 79 | fp.visible = true | 83 | fp.visible = true | ||
| 80 | fp.omniboxVisible = showOmnibox | 84 | fp.omniboxVisible = showOmnibox | ||
| 85 | + | fp.stateVersion++ | |||
| 86 | + | rollbackVersion := fp.stateVersion | |||
| 81 | // Release lock before Navigate: callbacks may block and must not run while | 87 | // Release lock before Navigate: callbacks may block and must not run while | ||
| 82 | // holding fp.mu. On error, rollback below restores visibility state. | 88 | // holding fp.mu. On error, rollback below restores visibility state. | ||
| 83 | fp.mu.Unlock() | 89 | fp.mu.Unlock() | ||
| @@ -85,8 +91,11 @@ func (fp *FloatingPane) ShowToggle(ctx context.Context) error { | |||||
| 85 | if navigateToBlank { | 91 | if navigateToBlank { | ||
| 86 | if err := fp.Navigate(ctx, defaultFloatingPaneURL); err != nil { | 92 | if err := fp.Navigate(ctx, defaultFloatingPaneURL); err != nil { | ||
| 87 | fp.mu.Lock() | 93 | fp.mu.Lock() | ||
| 88 | - | fp.visible = false | 94 | + | if fp.stateVersion == rollbackVersion { |
| 89 | - | fp.omniboxVisible = false | 95 | + | fp.visible = previousVisible |
| 96 | + | fp.omniboxVisible = previousOmniboxVisible | |||
| 97 | + | fp.stateVersion++ | |||
| 98 | + | } | |||
| 90 | fp.mu.Unlock() | 99 | fp.mu.Unlock() | ||
| 91 | return err | 100 | return err | ||
| 92 | } | 101 | } | ||
| @@ -102,6 +111,7 @@ func (fp *FloatingPane) Show() { | |||||
| 102 | defer fp.mu.Unlock() | 111 | defer fp.mu.Unlock() | ||
| 103 | fp.visible = true | 112 | fp.visible = true | ||
| 104 | fp.omniboxVisible = false | 113 | fp.omniboxVisible = false | ||
| 114 | + | fp.stateVersion++ | |||
| 105 | } | 115 | } | ||
| 106 | 116 | ||||
| 107 | // SessionStarted reports whether the floating pane has navigated at least once. | 117 | // SessionStarted reports whether the floating pane has navigated at least once. | ||
| @@ -119,13 +129,26 @@ func (fp *FloatingPane) ShowURL(ctx context.Context, url string) error { | |||||
| 119 | } | 129 | } | ||
| 120 | 130 | ||||
| 121 | fp.mu.Lock() | 131 | fp.mu.Lock() | ||
| 132 | + | previousVisible := fp.visible | |||
| 133 | + | previousOmniboxVisible := fp.omniboxVisible | |||
| 122 | fp.visible = true | 134 | fp.visible = true | ||
| 123 | fp.omniboxVisible = false | 135 | fp.omniboxVisible = false | ||
| 124 | shouldNavigate := !fp.sessionStarted || fp.currentURL != url | 136 | shouldNavigate := !fp.sessionStarted || fp.currentURL != url | ||
| 137 | + | fp.stateVersion++ | |||
| 138 | + | rollbackVersion := fp.stateVersion | |||
| 125 | fp.mu.Unlock() | 139 | fp.mu.Unlock() | ||
| 126 | 140 | ||||
| 127 | if shouldNavigate { | 141 | if shouldNavigate { | ||
| 128 | - | return fp.Navigate(ctx, url) | 142 | + | if err := fp.Navigate(ctx, url); err != nil { |
| 143 | + | fp.mu.Lock() | |||
| 144 | + | if fp.stateVersion == rollbackVersion { | |||
| 145 | + | fp.visible = previousVisible | |||
| 146 | + | fp.omniboxVisible = previousOmniboxVisible | |||
| 147 | + | fp.stateVersion++ | |||
| 148 | + | } | |||
| 149 | + | fp.mu.Unlock() | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 129 | } | 152 | } | ||
| 130 | 153 | ||||
| 131 | return nil | 154 | return nil | ||
| @@ -137,6 +160,7 @@ func (fp *FloatingPane) Hide(_ context.Context) { | |||||
| 137 | defer fp.mu.Unlock() | 160 | defer fp.mu.Unlock() | ||
| 138 | fp.visible = false | 161 | fp.visible = false | ||
| 139 | fp.omniboxVisible = false | 162 | fp.omniboxVisible = false | ||
| 163 | + | fp.stateVersion++ | |||
| 140 | } | 164 | } | ||
| 141 | 165 | ||||
| 142 | // Navigate loads a URL into the floating pane's persistent session. | 166 | // Navigate loads a URL into the floating pane's persistent session. | ||
| @@ -159,6 +183,7 @@ func (fp *FloatingPane) Navigate(ctx context.Context, url string) error { | |||||
| 159 | fp.mu.Lock() | 183 | fp.mu.Lock() | ||
| 160 | fp.currentURL = url | 184 | fp.currentURL = url | ||
| 161 | fp.sessionStarted = true | 185 | fp.sessionStarted = true | ||
| 186 | + | fp.stateVersion++ | |||
| 162 | fp.mu.Unlock() | 187 | fp.mu.Unlock() | ||
| 163 | 188 | ||||
| 164 | return nil | 189 | return nil | ||
| @@ -217,6 +242,7 @@ func (fp *FloatingPane) IsOmniboxVisible() bool { | |||||
| 217 | func (fp *FloatingPane) SetOmniboxVisible(visible bool) { | 242 | func (fp *FloatingPane) SetOmniboxVisible(visible bool) { | ||
| 218 | fp.mu.Lock() | 243 | fp.mu.Lock() | ||
| 219 | fp.omniboxVisible = visible | 244 | fp.omniboxVisible = visible | ||
| 245 | + | fp.stateVersion++ | |||
| 220 | fp.mu.Unlock() | 246 | fp.mu.Unlock() | ||
| 221 | } | 247 | } | ||
| 222 | 248 | ||||
| @@ -230,5 +256,6 @@ func (fp *FloatingPane) RecordLoadedURL(url string) { | |||||
| 230 | fp.mu.Lock() | 256 | fp.mu.Lock() | ||
| 231 | fp.currentURL = url | 257 | fp.currentURL = url | ||
| 232 | fp.sessionStarted = true | 258 | fp.sessionStarted = true | ||
| 259 | + | fp.stateVersion++ | |||
| 233 | fp.mu.Unlock() | 260 | fp.mu.Unlock() | ||
| 234 | } | 261 | } | ||
M internal/ui/component/floating_pane_test.go +52 -0
| @@ -2,6 +2,7 @@ package component_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| @@ -150,3 +151,54 @@ func TestFloatingPane_ConcurrentResizeAndParentSwitch(t *testing.T) { | |||
| 150 | 151 | assert.Positive(t, width) | |
| 151 | 152 | assert.Positive(t, height) | |
| 152 | 153 | } | |
| 154 | + | ||
| 155 | + | func TestFloatingPane_ShowURL_RollsBackVisibilityOnNavigateError(t *testing.T) { | |
| 156 | + | expectedErr := errors.New("navigate failed") | |
| 157 | + | fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{ | |
| 158 | + | WidthPct: 0.82, | |
| 159 | + | HeightPct: 0.72, | |
| 160 | + | FallbackWidth: 1200, | |
| 161 | + | FallbackHeight: 800, | |
| 162 | + | OnNavigate: func(context.Context, string) error { | |
| 163 | + | return expectedErr | |
| 164 | + | }, | |
| 165 | + | }) | |
| 166 | + | ||
| 167 | + | err := fp.ShowURL(context.Background(), "https://example.com") | |
| 168 | + | require.ErrorIs(t, err, expectedErr) | |
| 169 | + | assert.False(t, fp.IsVisible()) | |
| 170 | + | assert.False(t, fp.IsOmniboxVisible()) | |
| 171 | + | assert.Empty(t, fp.CurrentURL()) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestFloatingPane_ShowToggleNavigateError_DoesNotClobberConcurrentStateChange(t *testing.T) { | |
| 175 | + | navigationStarted := make(chan struct{}) | |
| 176 | + | releaseNavigation := make(chan struct{}) | |
| 177 | + | expectedErr := errors.New("navigate failed") | |
| 178 | + | ||
| 179 | + | fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{ | |
| 180 | + | WidthPct: 0.82, | |
| 181 | + | HeightPct: 0.72, | |
| 182 | + | FallbackWidth: 1200, | |
| 183 | + | FallbackHeight: 800, | |
| 184 | + | OnNavigate: func(context.Context, string) error { | |
| 185 | + | close(navigationStarted) | |
| 186 | + | <-releaseNavigation | |
| 187 | + | return expectedErr | |
| 188 | + | }, | |
| 189 | + | }) | |
| 190 | + | ||
| 191 | + | errCh := make(chan error, 1) | |
| 192 | + | go func() { | |
| 193 | + | errCh <- fp.ShowToggle(context.Background()) | |
| 194 | + | }() | |
| 195 | + | ||
| 196 | + | <-navigationStarted | |
| 197 | + | fp.Show() | |
| 198 | + | close(releaseNavigation) | |
| 199 | + | ||
| 200 | + | err := <-errCh | |
| 201 | + | require.ErrorIs(t, err, expectedErr) | |
| 202 | + | assert.True(t, fp.IsVisible()) | |
| 203 | + | assert.False(t, fp.IsOmniboxVisible()) | |
| 204 | + | } | |
| @@ -2,6 +2,7 @@ package component_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| @@ -150,3 +151,54 @@ func TestFloatingPane_ConcurrentResizeAndParentSwitch(t *testing.T) { | |||||
| 150 | assert.Positive(t, width) | 151 | assert.Positive(t, width) | ||
| 151 | assert.Positive(t, height) | 152 | assert.Positive(t, height) | ||
| 152 | } | 153 | } | ||
| 154 | + | ||||
| 155 | + | func TestFloatingPane_ShowURL_RollsBackVisibilityOnNavigateError(t *testing.T) { | |||
| 156 | + | expectedErr := errors.New("navigate failed") | |||
| 157 | + | fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{ | |||
| 158 | + | WidthPct: 0.82, | |||
| 159 | + | HeightPct: 0.72, | |||
| 160 | + | FallbackWidth: 1200, | |||
| 161 | + | FallbackHeight: 800, | |||
| 162 | + | OnNavigate: func(context.Context, string) error { | |||
| 163 | + | return expectedErr | |||
| 164 | + | }, | |||
| 165 | + | }) | |||
| 166 | + | ||||
| 167 | + | err := fp.ShowURL(context.Background(), "https://example.com") | |||
| 168 | + | require.ErrorIs(t, err, expectedErr) | |||
| 169 | + | assert.False(t, fp.IsVisible()) | |||
| 170 | + | assert.False(t, fp.IsOmniboxVisible()) | |||
| 171 | + | assert.Empty(t, fp.CurrentURL()) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestFloatingPane_ShowToggleNavigateError_DoesNotClobberConcurrentStateChange(t *testing.T) { | |||
| 175 | + | navigationStarted := make(chan struct{}) | |||
| 176 | + | releaseNavigation := make(chan struct{}) | |||
| 177 | + | expectedErr := errors.New("navigate failed") | |||
| 178 | + | ||||
| 179 | + | fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{ | |||
| 180 | + | WidthPct: 0.82, | |||
| 181 | + | HeightPct: 0.72, | |||
| 182 | + | FallbackWidth: 1200, | |||
| 183 | + | FallbackHeight: 800, | |||
| 184 | + | OnNavigate: func(context.Context, string) error { | |||
| 185 | + | close(navigationStarted) | |||
| 186 | + | <-releaseNavigation | |||
| 187 | + | return expectedErr | |||
| 188 | + | }, | |||
| 189 | + | }) | |||
| 190 | + | ||||
| 191 | + | errCh := make(chan error, 1) | |||
| 192 | + | go func() { | |||
| 193 | + | errCh <- fp.ShowToggle(context.Background()) | |||
| 194 | + | }() | |||
| 195 | + | ||||
| 196 | + | <-navigationStarted | |||
| 197 | + | fp.Show() | |||
| 198 | + | close(releaseNavigation) | |||
| 199 | + | ||||
| 200 | + | err := <-errCh | |||
| 201 | + | require.ErrorIs(t, err, expectedErr) | |||
| 202 | + | assert.True(t, fp.IsVisible()) | |||
| 203 | + | assert.False(t, fp.IsOmniboxVisible()) | |||
| 204 | + | } | |||
M internal/ui/coordinator/navigation.go +11 -3
| @@ -20,6 +20,7 @@ type OmniboxProvider interface { | |||
| 20 | 20 | ||
| 21 | 21 | // NavigationCoordinator handles URL navigation, history, and browser controls. | |
| 22 | 22 | type NavigationCoordinator struct { | |
| 23 | + | contextProvider func() context.Context | |
| 23 | 24 | navigateUC *usecase.NavigateUseCase | |
| 24 | 25 | contentCoord *ContentCoordinator | |
| 25 | 26 | omniboxProvider OmniboxProvider | |
| @@ -35,8 +36,12 @@ func NewNavigationCoordinator( | |||
| 35 | 36 | ) *NavigationCoordinator { | |
| 36 | 37 | log := logging.FromContext(ctx) | |
| 37 | 38 | log.Debug().Msg("creating navigation coordinator") | |
| 39 | + | callbackLogger := *log | |
| 38 | 40 | ||
| 39 | 41 | return &NavigationCoordinator{ | |
| 42 | + | contextProvider: func() context.Context { | |
| 43 | + | return logging.WithContext(context.Background(), callbackLogger) | |
| 44 | + | }, | |
| 40 | 45 | navigateUC: navigateUC, | |
| 41 | 46 | contentCoord: contentCoord, | |
| 42 | 47 | } | |
| @@ -47,9 +52,12 @@ func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) { | |||
| 47 | 52 | c.omniboxProvider = provider | |
| 48 | 53 | if c.omniboxProvider != nil { | |
| 49 | 54 | c.omniboxProvider.SetOmniboxOnNavigate(func(url string) { | |
| 50 | - | ctx := context.Background() | |
| 51 | - | if err := c.Navigate(ctx, url); err != nil { | |
| 52 | - | logging.FromContext(ctx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed") | |
| 55 | + | navCtx := context.Background() | |
| 56 | + | if c.contextProvider != nil { | |
| 57 | + | navCtx = c.contextProvider() | |
| 58 | + | } | |
| 59 | + | if err := c.Navigate(navCtx, url); err != nil { | |
| 60 | + | logging.FromContext(navCtx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed") | |
| 53 | 61 | } | |
| 54 | 62 | }) | |
| 55 | 63 | } | |
| @@ -20,6 +20,7 @@ type OmniboxProvider interface { | |||||
| 20 | 20 | ||||
| 21 | // NavigationCoordinator handles URL navigation, history, and browser controls. | 21 | // NavigationCoordinator handles URL navigation, history, and browser controls. | ||
| 22 | type NavigationCoordinator struct { | 22 | type NavigationCoordinator struct { | ||
| 23 | + | contextProvider func() context.Context | |||
| 23 | navigateUC *usecase.NavigateUseCase | 24 | navigateUC *usecase.NavigateUseCase | ||
| 24 | contentCoord *ContentCoordinator | 25 | contentCoord *ContentCoordinator | ||
| 25 | omniboxProvider OmniboxProvider | 26 | omniboxProvider OmniboxProvider | ||
| @@ -35,8 +36,12 @@ func NewNavigationCoordinator( | |||||
| 35 | ) *NavigationCoordinator { | 36 | ) *NavigationCoordinator { | ||
| 36 | log := logging.FromContext(ctx) | 37 | log := logging.FromContext(ctx) | ||
| 37 | log.Debug().Msg("creating navigation coordinator") | 38 | log.Debug().Msg("creating navigation coordinator") | ||
| 39 | + | callbackLogger := *log | |||
| 38 | 40 | ||||
| 39 | return &NavigationCoordinator{ | 41 | return &NavigationCoordinator{ | ||
| 42 | + | contextProvider: func() context.Context { | |||
| 43 | + | return logging.WithContext(context.Background(), callbackLogger) | |||
| 44 | + | }, | |||
| 40 | navigateUC: navigateUC, | 45 | navigateUC: navigateUC, | ||
| 41 | contentCoord: contentCoord, | 46 | contentCoord: contentCoord, | ||
| 42 | } | 47 | } | ||
| @@ -47,9 +52,12 @@ func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) { | |||||
| 47 | c.omniboxProvider = provider | 52 | c.omniboxProvider = provider | ||
| 48 | if c.omniboxProvider != nil { | 53 | if c.omniboxProvider != nil { | ||
| 49 | c.omniboxProvider.SetOmniboxOnNavigate(func(url string) { | 54 | c.omniboxProvider.SetOmniboxOnNavigate(func(url string) { | ||
| 50 | - | ctx := context.Background() | 55 | + | navCtx := context.Background() |
| 51 | - | if err := c.Navigate(ctx, url); err != nil { | 56 | + | if c.contextProvider != nil { |
| 52 | - | logging.FromContext(ctx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed") | 57 | + | navCtx = c.contextProvider() |
| 58 | + | } | |||
| 59 | + | if err := c.Navigate(navCtx, url); err != nil { | |||
| 60 | + | logging.FromContext(navCtx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed") | |||
| 53 | } | 61 | } | ||
| 54 | }) | 62 | }) | ||
| 55 | } | 63 | } | ||
M internal/ui/floating_pane_test.go +3 -0
| @@ -96,6 +96,9 @@ func TestFloatingAllocationRect_ClampsToOverlayBounds(t *testing.T) { | |||
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | 98 | func TestWriteOverlayAllocation_WritesRectangleFields(t *testing.T) { | |
| 99 | + | // cGtkAllocation mirrors GTK's GdkRectangle/GtkAllocation layout used by | |
| 100 | + | // writeOverlayAllocation: four int32 fields in order. allocationPtr uses | |
| 101 | + | // unsafe.Pointer to simulate the C allocation callback memory. | |
| 99 | 102 | type cGtkAllocation struct { | |
| 100 | 103 | X int32 | |
| 101 | 104 | Y int32 | |
| @@ -96,6 +96,9 @@ func TestFloatingAllocationRect_ClampsToOverlayBounds(t *testing.T) { | |||||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | func TestWriteOverlayAllocation_WritesRectangleFields(t *testing.T) { | 98 | func TestWriteOverlayAllocation_WritesRectangleFields(t *testing.T) { | ||
| 99 | + | // cGtkAllocation mirrors GTK's GdkRectangle/GtkAllocation layout used by | |||
| 100 | + | // writeOverlayAllocation: four int32 fields in order. allocationPtr uses | |||
| 101 | + | // unsafe.Pointer to simulate the C allocation callback memory. | |||
| 99 | type cGtkAllocation struct { | 102 | type cGtkAllocation struct { | ||
| 100 | X int32 | 103 | X int32 | ||
| 101 | Y int32 | 104 | Y int32 | ||