v0.1 bf7c821

history.go RawHistoryFolder

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

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