v0.20.1 d9a391f

adapter.go RawHistoryFolder

1// Package desktop provides desktop environment integration for Linux (XDG).
2package desktop
3
4import (
5	"context"
6	"fmt"
7	"os"
8	"os/exec"
9	"path/filepath"
10	"strings"
11
12	"github.com/bnema/dumber/internal/application/port"
13	"github.com/bnema/dumber/internal/logging"
14)
15
16const (
17	appName         = "dumber"
18	desktopFileName = "dumber.desktop"
19	iconFileName    = "dumber.svg"
20	filePerm        = 0644
21	dirPerm         = 0755
22)
23
24// desktopFileTemplate is the freedesktop.org desktop entry format.
25// %s placeholder for executable path.
26const desktopFileTemplate = `[Desktop Entry]
27Version=1.1
28Type=Application
29Name=Dumber
30GenericName=Dumber Browser
31Comment=It's a browser but dumber
32Exec=%s browse %%U
33Icon=dumber
34Terminal=false
35Categories=Network;WebBrowser;
36MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;
37StartupNotify=true
38StartupWMClass=dumber
39`
40
41// Adapter implements port.DesktopIntegration using XDG tools.
42type Adapter struct {
43	xdgSettingsPath string
44	updateDesktopDB string
45}
46
47// New creates a new desktop integration adapter.
48func New() port.DesktopIntegration {
49	a := &Adapter{}
50
51	// Detect xdg-settings
52	if path, err := exec.LookPath("xdg-settings"); err == nil {
53		a.xdgSettingsPath = path
54	}
55
56	// Detect update-desktop-database (optional)
57	if path, err := exec.LookPath("update-desktop-database"); err == nil {
58		a.updateDesktopDB = path
59	}
60
61	return a
62}
63
64// getApplicationsDir returns the XDG applications directory.
65func getApplicationsDir() (string, error) {
66	dataHome := os.Getenv("XDG_DATA_HOME")
67	if dataHome == "" {
68		home, err := os.UserHomeDir()
69		if err != nil {
70			return "", fmt.Errorf("get home dir: %w", err)
71		}
72		dataHome = filepath.Join(home, ".local", "share")
73	}
74	return filepath.Join(dataHome, "applications"), nil
75}
76
77// getDesktopFilePath returns the full path to the desktop file.
78func getDesktopFilePath() (string, error) {
79	appDir, err := getApplicationsDir()
80	if err != nil {
81		return "", err
82	}
83	return filepath.Join(appDir, desktopFileName), nil
84}
85
86// getIconFilePath returns the full path to the icon file.
87// Uses hicolor theme scalable apps directory.
88func getIconFilePath() (string, error) {
89	dataHome := os.Getenv("XDG_DATA_HOME")
90	if dataHome == "" {
91		home, err := os.UserHomeDir()
92		if err != nil {
93			return "", fmt.Errorf("get home dir: %w", err)
94		}
95		dataHome = filepath.Join(home, ".local", "share")
96	}
97	return filepath.Join(dataHome, "icons", "hicolor", "scalable", "apps", iconFileName), nil
98}
99
100// getExecutablePath returns the path to the dumber executable.
101func getExecutablePath() (string, error) {
102	// First try the running executable
103	execPath, err := os.Executable()
104	if err == nil {
105		// Resolve symlinks
106		resolved, symlinkErr := filepath.EvalSymlinks(execPath)
107		if symlinkErr == nil {
108			execPath = resolved
109		}
110		return execPath, nil
111	}
112
113	// Fallback to PATH lookup
114	path, err := exec.LookPath(appName)
115	if err != nil {
116		return "", fmt.Errorf("cannot find %s executable: %w", appName, err)
117	}
118	return path, nil
119}
120
121// GetStatus checks the current desktop integration state.
122func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus, error) {
123	log := logging.FromContext(ctx)
124	status := &port.DesktopIntegrationStatus{}
125
126	// Check desktop file
127	desktopPath, err := getDesktopFilePath()
128	if err != nil {
129		return nil, err
130	}
131	status.DesktopFilePath = desktopPath
132
133	if _, statErr := os.Stat(desktopPath); statErr == nil {
134		status.DesktopFileInstalled = true
135	}
136
137	// Check icon file
138	iconPath, err := getIconFilePath()
139	if err != nil {
140		return nil, err
141	}
142	status.IconFilePath = iconPath
143
144	if _, iconStatErr := os.Stat(iconPath); iconStatErr == nil {
145		status.IconInstalled = true
146	}
147
148	// Check executable
149	execPath, err := getExecutablePath()
150	if err == nil {
151		status.ExecutablePath = execPath
152	}
153
154	// Check default browser
155	if a.xdgSettingsPath != "" {
156		out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
157		if err == nil {
158			currentDefault := strings.TrimSpace(string(out))
159			status.IsDefaultBrowser = currentDefault == desktopFileName
160		}
161	}
162
163	log.Debug().
164		Bool("desktop_installed", status.DesktopFileInstalled).
165		Bool("icon_installed", status.IconInstalled).
166		Bool("default", status.IsDefaultBrowser).
167		Str("desktop_path", status.DesktopFilePath).
168		Str("icon_path", status.IconFilePath).
169		Str("exec_path", status.ExecutablePath).
170		Msg("desktop integration status")
171
172	return status, nil
173}
174
175// InstallDesktopFile writes the desktop file to XDG applications directory.
176func (a *Adapter) InstallDesktopFile(ctx context.Context) (string, error) {
177	log := logging.FromContext(ctx)
178
179	execPath, err := getExecutablePath()
180	if err != nil {
181		return "", err
182	}
183
184	desktopPath, err := getDesktopFilePath()
185	if err != nil {
186		return "", err
187	}
188
189	// Ensure applications directory exists
190	appDir := filepath.Dir(desktopPath)
191	if err := os.MkdirAll(appDir, dirPerm); err != nil {
192		return "", fmt.Errorf("create applications dir: %w", err)
193	}
194
195	// Generate desktop file content
196	content := fmt.Sprintf(desktopFileTemplate, execPath)
197
198	// Write desktop file
199	if err := os.WriteFile(desktopPath, []byte(content), filePerm); err != nil {
200		return "", fmt.Errorf("write desktop file: %w", err)
201	}
202
203	log.Info().Str("path", desktopPath).Msg("desktop file installed")
204
205	// Update desktop database (optional, helps with some DEs)
206	if a.updateDesktopDB != "" {
207		if err := exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run(); err != nil {
208			log.Debug().Err(err).Msg("update-desktop-database failed (non-fatal)")
209		}
210	}
211
212	return desktopPath, nil
213}
214
215// RemoveDesktopFile removes the desktop file from XDG applications directory.
216func (a *Adapter) RemoveDesktopFile(ctx context.Context) error {
217	log := logging.FromContext(ctx)
218
219	desktopPath, err := getDesktopFilePath()
220	if err != nil {
221		return err
222	}
223
224	// Check if exists
225	if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
226		log.Debug().Str("path", desktopPath).Msg("desktop file not found (already removed)")
227		return nil
228	}
229
230	if err := os.Remove(desktopPath); err != nil {
231		return fmt.Errorf("remove desktop file: %w", err)
232	}
233
234	log.Info().Str("path", desktopPath).Msg("desktop file removed")
235
236	// Update desktop database
237	if a.updateDesktopDB != "" {
238		appDir := filepath.Dir(desktopPath)
239		_ = exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run()
240	}
241
242	return nil
243}
244
245// InstallIcon writes the icon file to XDG icons directory.
246func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) {
247	if a == nil {
248		return "", fmt.Errorf("desktop adapter is nil")
249	}
250	log := logging.FromContext(ctx)
251
252	iconPath, err := getIconFilePath()
253	if err != nil {
254		return "", err
255	}
256
257	// Ensure icons directory exists
258	iconDir := filepath.Dir(iconPath)
259	if err := os.MkdirAll(iconDir, dirPerm); err != nil {
260		return "", fmt.Errorf("create icons dir: %w", err)
261	}
262
263	// Write icon file
264	if err := os.WriteFile(iconPath, svgData, filePerm); err != nil {
265		return "", fmt.Errorf("write icon file: %w", err)
266	}
267
268	log.Info().Str("path", iconPath).Msg("icon file installed")
269
270	return iconPath, nil
271}
272
273// RemoveIcon removes the icon file from XDG icons directory.
274func (a *Adapter) RemoveIcon(ctx context.Context) error {
275	if a == nil {
276		return fmt.Errorf("desktop adapter is nil")
277	}
278	log := logging.FromContext(ctx)
279
280	iconPath, err := getIconFilePath()
281	if err != nil {
282		return err
283	}
284
285	// Check if exists
286	if _, err := os.Stat(iconPath); os.IsNotExist(err) {
287		log.Debug().Str("path", iconPath).Msg("icon file not found (already removed)")
288		return nil
289	}
290
291	if err := os.Remove(iconPath); err != nil {
292		return fmt.Errorf("remove icon file: %w", err)
293	}
294
295	log.Info().Str("path", iconPath).Msg("icon file removed")
296
297	return nil
298}
299
300// SetAsDefaultBrowser sets dumber as the default web browser.
301func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
302	log := logging.FromContext(ctx)
303
304	if a.xdgSettingsPath == "" {
305		return fmt.Errorf("xdg-settings not found (install xdg-utils)")
306	}
307
308	// Check if desktop file is installed
309	desktopPath, err := getDesktopFilePath()
310	if err != nil {
311		return err
312	}
313	if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
314		return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")
315	}
316
317	// Set default browser
318	cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFileName)
319	if out, err := cmd.CombinedOutput(); err != nil {
320		return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
321	}
322
323	log.Info().Msg("dumber set as default browser")
324	return nil
325}
326
327// UnsetAsDefaultBrowser resets default browser if dumber is currently default.
328func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
329	log := logging.FromContext(ctx)
330
331	if a.xdgSettingsPath == "" {
332		return nil // No xdg-settings, nothing to do
333	}
334
335	// Check current default
336	out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
337	if err != nil {
338		return nil // Can't determine, skip
339	}
340
341	currentDefault := strings.TrimSpace(string(out))
342	if currentDefault != desktopFileName {
343		log.Debug().Str("current", currentDefault).Msg("dumber is not default browser, nothing to reset")
344		return nil
345	}
346
347	// xdg-settings doesn't have a "reset" - user must set another browser
348	log.Warn().Msg("dumber was default browser - user should set a new default")
349	return nil
350}

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