v0.1 bf7c821

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

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