v0.6.0 5c50dd3

history.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"database/sql"
6	"fmt"
7	"log"
8	"os"
9	"strconv"
10	"strings"
11	"text/tabwriter"
12	"time"
13
14	"github.com/spf13/cobra"
15)
16
17const (
18	unknownValue        = "Unknown"
19	defaultHistoryLimit = 20
20	defaultSearchLimit  = 10
21	maxURLDisplay       = 50
22	maxTitleDisplay     = 40
23	maxURLCompact       = 60
24	maxTitleCompact     = 30
25	tabSpacing          = 2
26)
27
28// NewHistoryCmd creates the history command
29func NewHistoryCmd() *cobra.Command {
30	cmd := &cobra.Command{
31		Use:   "history",
32		Short: "Manage browsing history",
33		Long: `Manage browsing history with various subcommands:
34  list   - Show recent browsing history
35  search - Search through history
36  clear  - Clear history (with confirmation)`,
37		RunE: func(cmd *cobra.Command, args []string) error {
38			// Default to list if no subcommand
39			return listHistory(cmd, args)
40		},
41	}
42
43	// List subcommand (also default behavior)
44	listCmd := &cobra.Command{
45		Use:   "list",
46		Short: "List recent browsing history",
47		Long:  `Display recent browsing history with visit counts and timestamps.`,
48		RunE:  listHistory,
49	}
50	listCmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show")
51	listCmd.Flags().BoolP("verbose", "v", false, "Show detailed information")
52
53	// Search subcommand
54	searchCmd := &cobra.Command{
55		Use:   "search <query>",
56		Short: "Search through browsing history",
57		Long:  `Search for URLs and titles in browsing history.`,
58		Args:  cobra.ExactArgs(1),
59		RunE:  searchHistory,
60	}
61	searchCmd.Flags().IntP("limit", "n", defaultSearchLimit, "Number of search results to show")
62
63	// Clear subcommand
64	clearCmd := &cobra.Command{
65		Use:   "clear",
66		Short: "Clear browsing history",
67		Long:  `Clear all browsing history. This action cannot be undone.`,
68		RunE:  clearHistory,
69	}
70	clearCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
71
72	// Stats subcommand
73	statsCmd := &cobra.Command{
74		Use:   "stats",
75		Short: "Show history statistics",
76		Long:  `Display statistics about your browsing history.`,
77		RunE:  showStats,
78	}
79
80	// Add global flags
81	cmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show")
82	cmd.Flags().BoolP("verbose", "v", false, "Show detailed information")
83
84	// Add subcommands
85	cmd.AddCommand(listCmd)
86	cmd.AddCommand(searchCmd)
87	cmd.AddCommand(clearCmd)
88	cmd.AddCommand(statsCmd)
89
90	return cmd
91}
92
93// listHistory displays recent browsing history
94func listHistory(cmd *cobra.Command, _ []string) error {
95	cli, err := NewCLI()
96	if err != nil {
97		return fmt.Errorf("failed to initialize CLI: %w", err)
98	}
99	defer func() {
100		if err := cli.Close(); err != nil {
101			fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
102		}
103	}()
104
105	ctx := context.Background()
106
107	limit, _ := cmd.Flags().GetInt("limit")
108	verbose, _ := cmd.Flags().GetBool("verbose")
109
110	history, err := cli.Queries.GetHistory(ctx, int64(limit))
111	if err != nil {
112		return fmt.Errorf("failed to get history: %w", err)
113	}
114
115	if len(history) == 0 {
116		fmt.Println("No browsing history found.")
117		return nil
118	}
119
120	// Create tabwriter for aligned output
121	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
122	defer func() {
123		if err := w.Flush(); err != nil {
124			log.Printf("Warning: failed to flush output: %v", err)
125		}
126	}()
127
128	if verbose {
129		if _, err := fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE"); err != nil {
130			log.Printf("Warning: failed to write header: %v", err)
131		}
132		if _, err := fmt.Fprintln(w, "--\t------\t------------\t---\t-----"); err != nil {
133			log.Printf("Warning: failed to write separator: %v", err)
134		}
135
136		for _, entry := range history {
137			visits := "1"
138			if entry.VisitCount.Valid {
139				visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
140			}
141
142			lastVisited := unknownValue
143			if entry.LastVisited.Valid {
144				lastVisited = entry.LastVisited.Time.Format("2006-01-02 15:04")
145			}
146
147			title := entry.Title.String
148			if !entry.Title.Valid || title == "" {
149				title = "-"
150			}
151
152			url := truncateString(entry.Url, maxURLDisplay)
153			title = truncateString(title, maxTitleDisplay)
154
155			if _, err := fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
156				entry.ID, visits, lastVisited, url, title); err != nil {
157				log.Printf("Warning: failed to write history entry: %v", err)
158			}
159		}
160	} else {
161		if _, err := fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL"); err != nil {
162			log.Printf("Warning: failed to write header: %v", err)
163		}
164		if _, err := fmt.Fprintln(w, "------\t------------\t---"); err != nil {
165			log.Printf("Warning: failed to write separator: %v", err)
166		}
167
168		for _, entry := range history {
169			visits := "1"
170			if entry.VisitCount.Valid {
171				visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
172			}
173
174			lastVisited := unknownValue
175			if entry.LastVisited.Valid {
176				// Show relative time for recent entries
177				if time.Since(entry.LastVisited.Time) < 24*time.Hour {
178					lastVisited = entry.LastVisited.Time.Format("15:04")
179				} else {
180					lastVisited = entry.LastVisited.Time.Format("Jan 02")
181				}
182			}
183
184			url := entry.Url
185			if len(url) > maxURLCompact {
186				url = url[:57] + "..."
187			}
188
189			if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", visits, lastVisited, url); err != nil {
190				log.Printf("Warning: failed to write history entry: %v", err)
191			}
192		}
193	}
194
195	return nil
196}
197
198// searchHistory searches through browsing history
199func searchHistory(cmd *cobra.Command, args []string) error {
200	cli, err := NewCLI()
201	if err != nil {
202		return fmt.Errorf("failed to initialize CLI: %w", err)
203	}
204	defer func() {
205		if err := cli.Close(); err != nil {
206			fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
207		}
208	}()
209
210	ctx := context.Background()
211	query := args[0]
212	limit, _ := cmd.Flags().GetInt("limit")
213
214	// Search both URL and title
215	searchQuery := sql.NullString{String: query, Valid: true}
216	results, err := cli.Queries.SearchHistory(ctx, searchQuery, searchQuery, int64(limit))
217	if err != nil {
218		return fmt.Errorf("failed to search history: %w", err)
219	}
220
221	if len(results) == 0 {
222		fmt.Printf("No history entries found matching '%s'.\n", query)
223		return nil
224	}
225
226	fmt.Printf("Found %d result(s) for '%s':\n\n", len(results), query)
227
228	// Create tabwriter for aligned output
229	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
230	defer func() {
231		if err := w.Flush(); err != nil {
232			log.Printf("Warning: failed to flush output: %v", err)
233		}
234	}()
235
236	if _, err := fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL\tTITLE"); err != nil {
237		log.Printf("Warning: failed to write header: %v", err)
238	}
239	if _, err := fmt.Fprintln(w, "------\t------------\t---\t-----"); err != nil {
240		log.Printf("Warning: failed to write separator: %v", err)
241	}
242
243	for _, entry := range results {
244		visits := "1"
245		if entry.VisitCount.Valid {
246			visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
247		}
248
249		lastVisited := "Unknown"
250		if entry.LastVisited.Valid {
251			lastVisited = entry.LastVisited.Time.Format("Jan 02 15:04")
252		}
253
254		title := entry.Title.String
255		if !entry.Title.Valid || title == "" {
256			title = "-"
257		}
258
259		url := truncateString(entry.Url, maxURLDisplay)
260		title = truncateString(title, maxTitleCompact)
261
262		if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", visits, lastVisited, url, title); err != nil {
263			log.Printf("Warning: failed to write history entry: %v", err)
264		}
265	}
266
267	return nil
268}
269
270// clearHistory clears all browsing history
271func clearHistory(cmd *cobra.Command, _ []string) error {
272	force, _ := cmd.Flags().GetBool("force")
273
274	// Confirmation unless --force is used
275	if !force {
276		fmt.Print("This will permanently delete all browsing history. Continue? [y/N]: ")
277		var response string
278		_, _ = fmt.Scanln(&response)
279		response = strings.ToLower(strings.TrimSpace(response))
280
281		if response != "y" && response != "yes" {
282			fmt.Println("Operation cancelled.")
283			return nil
284		}
285	}
286
287	cli, err := NewCLI()
288	if err != nil {
289		return fmt.Errorf("failed to initialize CLI: %w", err)
290	}
291	defer func() {
292		if err := cli.Close(); err != nil {
293			fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
294		}
295	}()
296
297	// Execute DELETE query
298	result, err := cli.DB.Exec("DELETE FROM history")
299	if err != nil {
300		return fmt.Errorf("failed to clear history: %w", err)
301	}
302
303	rowsAffected, err := result.RowsAffected()
304	if err != nil {
305		return fmt.Errorf("failed to get affected rows: %w", err)
306	}
307
308	fmt.Printf("Successfully cleared %d history entries.\n", rowsAffected)
309	return nil
310}
311
312// showStats displays statistics about browsing history
313func showStats(_ *cobra.Command, _ []string) error {
314	cli, err := NewCLI()
315	if err != nil {
316		return fmt.Errorf("failed to initialize CLI: %w", err)
317	}
318	defer func() {
319		if err := cli.Close(); err != nil {
320			fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
321		}
322	}()
323
324	ctx := context.Background()
325
326	// Get total entries
327	var totalEntries int64
328	err = cli.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM history").Scan(&totalEntries)
329	if err != nil {
330		return fmt.Errorf("failed to get total entries: %w", err)
331	}
332
333	// Get total visits
334	var totalVisits sql.NullInt64
335	err = cli.DB.QueryRowContext(ctx, "SELECT SUM(visit_count) FROM history").Scan(&totalVisits)
336	if err != nil {
337		return fmt.Errorf("failed to get total visits: %w", err)
338	}
339
340	// Get date range
341	var oldestDate, newestDate sql.NullTime
342	err = cli.DB.QueryRowContext(ctx, "SELECT MIN(created_at), MAX(last_visited) FROM history").Scan(&oldestDate, &newestDate)
343	if err != nil {
344		return fmt.Errorf("failed to get date range: %w", err)
345	}
346
347	// Get top domains
348	rows, err := cli.DB.QueryContext(ctx, `
349		SELECT 
350			CASE 
351				WHEN url LIKE 'http://%' THEN substr(url, 8)
352				WHEN url LIKE 'https://%' THEN substr(url, 9)
353				ELSE url
354			END as domain,
355			COUNT(*) as count,
356			SUM(visit_count) as total_visits
357		FROM history 
358		GROUP BY domain 
359		ORDER BY total_visits DESC 
360		LIMIT 5
361	`)
362	if err != nil {
363		return fmt.Errorf("failed to get top domains: %w", err)
364	}
365	defer func() {
366		if err := rows.Close(); err != nil {
367			log.Printf("Warning: failed to close rows: %v", err)
368		}
369	}()
370
371	fmt.Println("Browsing History Statistics")
372	fmt.Println("==========================")
373	fmt.Printf("Total unique URLs: %d\n", totalEntries)
374
375	if totalVisits.Valid {
376		fmt.Printf("Total visits: %d\n", totalVisits.Int64)
377	}
378
379	if oldestDate.Valid && newestDate.Valid {
380		fmt.Printf("Date range: %s to %s\n",
381			oldestDate.Time.Format("2006-01-02"),
382			newestDate.Time.Format("2006-01-02"))
383	}
384
385	fmt.Println("\nTop 5 domains:")
386	fmt.Println("--------------")
387
388	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
389	defer func() {
390		if err := w.Flush(); err != nil {
391			log.Printf("Warning: failed to flush output: %v", err)
392		}
393	}()
394
395	if _, err := fmt.Fprintln(w, "DOMAIN\tURLs\tVISITS"); err != nil {
396		log.Printf("Warning: failed to write header: %v", err)
397	}
398	if _, err := fmt.Fprintln(w, "------\t----\t------"); err != nil {
399		log.Printf("Warning: failed to write separator: %v", err)
400	}
401
402	for rows.Next() {
403		var domain string
404		var urlCount, visitCount int64
405
406		if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil {
407			continue
408		}
409
410		// Extract just the domain part
411		if idx := strings.Index(domain, "/"); idx > 0 {
412			domain = domain[:idx]
413		}
414
415		if _, err := fmt.Fprintf(w, "%s\t%d\t%d\n", domain, urlCount, visitCount); err != nil {
416			log.Printf("Warning: failed to write domain entry: %v", err)
417		}
418	}
419
420	return nil
421}

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