v0.2.0 759cc92

browse.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"database/sql"
6	"fmt"
7	"net/url"
8	"os"
9	"os/exec"
10	"regexp"
11	"strings"
12
13	"github.com/bnema/dumber/internal/config"
14
15	"github.com/spf13/cobra"
16)
17
18// NewBrowseCmd creates the browse command
19func NewBrowseCmd() *cobra.Command {
20    var renderingMode string
21
22    cmd := &cobra.Command{
23        Use:   "browse <url|query>",
24        Short: "Browse a URL or search query",
25		Long: `Browse a URL directly or use search shortcuts like:
26  g:golang      -> Google search for "golang"
27  gh:cobra      -> GitHub search for "cobra"
28  yt:tutorials  -> YouTube search for "tutorials"
29  w:go language -> Wikipedia search for "go language"
30
31Direct URLs are also supported:
32  https://example.com
33  example.com (automatically adds https://)`,
34		Args: cobra.ExactArgs(1),
35        RunE: func(_ *cobra.Command, args []string) error {
36            // Propagate rendering mode to GUI process via env var
37            if renderingMode != "" {
38                rm := strings.ToLower(renderingMode)
39                switch rm {
40                case "auto", "gpu", "cpu":
41                    _ = os.Setenv("DUMBER_RENDERING_MODE", rm)
42                default:
43                    return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode)
44                }
45            }
46            cli, err := NewCLI()
47            if err != nil {
48                return fmt.Errorf("failed to initialize CLI: %w", err)
49            }
50			defer func() {
51				if closeErr := cli.Close(); closeErr != nil {
52					fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
53				}
54			}()
55
56			return browse(cli, args[0])
57		},
58	}
59
60    // Flags
61    cmd.Flags().StringVar(&renderingMode, "rendering-mode", "", "Rendering mode: auto|gpu|cpu")
62
63    return cmd
64}
65
66// browse handles the core browsing logic
67func browse(cli *CLI, input string) error {
68	ctx := context.Background()
69
70	// Parse the input to determine URL or shortcut
71	finalURL, err := parseInput(ctx, cli, input)
72	if err != nil {
73		return fmt.Errorf("failed to parse input: %w", err)
74	}
75
76	// Record in history
77	if err := recordVisit(ctx, cli, finalURL, ""); err != nil {
78		// Don't fail the browse operation if history recording fails
79		fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)
80	}
81
82	// Open URL using configuration
83	return openURLWithConfig(finalURL, cli.Config)
84}
85
86// parseInput processes user input and returns the final URL to browse
87func parseInput(ctx context.Context, cli *CLI, input string) (string, error) {
88	// Check if it's a shortcut (format: "prefix:query")
89	if strings.Contains(input, ":") && !strings.Contains(input, "://") {
90		parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only
91		if len(parts) == 2 {                   //nolint:mnd // expect prefix and query parts
92			shortcut := strings.TrimSpace(parts[0])
93			query := strings.TrimSpace(parts[1])
94
95			if query == "" {
96				return "", fmt.Errorf("empty query for shortcut '%s'", shortcut)
97			}
98
99			// First check configuration-based shortcuts
100			if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists {
101				return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil
102			}
103
104			// Fallback to database shortcuts for backward compatibility
105			shortcuts, err := cli.Queries.GetShortcuts(ctx)
106			if err != nil {
107				return "", fmt.Errorf("failed to get shortcuts: %w", err)
108			}
109
110			for _, s := range shortcuts {
111				if s.Shortcut == shortcut {
112					return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil
113				}
114			}
115
116			return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
117		}
118	}
119
120	// Check if it's already a valid URL
121	if isValidURL(input) {
122		return input, nil
123	}
124
125	// Try to make it a URL by adding https://
126	if !strings.Contains(input, "://") {
127		candidate := "https://" + input
128		if isValidURL(candidate) {
129			return candidate, nil
130		}
131	}
132
133	// If all else fails, search Google for it
134	return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil
135}
136
137// isValidURL checks if a string is a valid URL
138func isValidURL(str string) bool {
139	u, err := url.Parse(str)
140	if err != nil {
141		return false
142	}
143
144	// Must have a scheme and host
145	if u.Scheme == "" || u.Host == "" {
146		return false
147	}
148
149	// Must be http or https
150	if u.Scheme != "http" && u.Scheme != "https" {
151		return false
152	}
153
154	// Basic domain validation
155	domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` +
156		`(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
157	return domainRegex.MatchString(u.Host)
158}
159
160// recordVisit adds or updates a URL visit in the history
161func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
162	var sqlTitle sql.NullString
163	if title != "" {
164		sqlTitle = sql.NullString{String: title, Valid: true}
165	}
166
167	return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle)
168}
169
170// openURL opens a URL using the configured browser
171func openURL(url string) error {
172	return openURLWithConfig(url, nil)
173}
174
175// openURLWithConfig opens a URL using our built-in browser
176func openURLWithConfig(url string, cfg *config.Config) error {
177	// Get the path to our own executable
178	executable, err := os.Executable()
179	if err != nil {
180		return fmt.Errorf("failed to get executable path: %w", err)
181	}
182
183	// Launch our own browser in browse mode with the URL directly
184	cmd := exec.Command(executable, "browse", url)
185
186	// Start the browser in detached mode
187	if err := cmd.Start(); err != nil {
188		return fmt.Errorf("failed to open URL in built-in browser: %w", err)
189	}
190
191	// Don't wait for the browser process to exit
192	go func() {
193		_ = cmd.Wait()
194	}()
195
196	fmt.Printf("Opening: %s (using built-in browser)\n", url)
197	return nil
198}
199
200// commandExists checks if a command is available in the system PATH
201func commandExists(cmd string) bool {
202	_, err := exec.LookPath(cmd)
203	return err == nil
204}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close