v0.9.0 636c44c

Homepage.svelte RawHistoryFolder

1<script lang="ts">
2  import { onMount } from 'svelte';
3  import Footer from '$components/Footer.svelte';
4  // Icons disabled for investigation: removed @lucide/svelte imports
5
6  interface HistoryItem {
7    id: number;
8    url: string;
9    title: string;
10    favicon_url?: string;
11    visit_count: number;
12    last_visited: string;
13    created_at: string;
14  }
15
16  interface Shortcut {
17    key: string;
18    description: string;
19    action: () => void;
20  }
21
22  interface HistoryStats {
23    total_entries: number;
24    recent_count: number;
25    oldest_entry?: string;
26    newest_entry?: string;
27  }
28
29  let historyItems = $state<HistoryItem[]>([]);
30  let topVisited = $state<HistoryItem[]>([]);
31  let shortcuts = $state<Shortcut[]>([]);
32  let stats = $state<HistoryStats | null>(null);
33  let historyLoading = $state(true);
34  let topVisitedLoading = $state(true);
35  let shortcutsLoading = $state(true);
36  let statsLoading = $state(true);
37  let deletingIds = $state<number[]>([]);
38  let historyOffset = $state(0);
39  let hasMoreHistory = $state(true);
40  let loadingMoreHistory = $state(false);
41
42  // Function for checking if an item is being deleted
43  const isDeleting = (id: number) => deletingIds.includes(id);
44
45  // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
46  const initializeShortcuts = () => {
47    shortcuts = [
48      // Browser shortcuts
49      {
50        key: 'Ctrl+L',
51        description: 'Focus address bar',
52        action: () => console.log('Ctrl+L triggered')
53      },
54      {
55        key: 'Ctrl+F',
56        description: 'Find in page',
57        action: () => console.log('Ctrl+F triggered')
58      },
59      {
60        key: 'Ctrl+Shift+C',
61        description: 'Copy URL',
62        action: () => console.log('Ctrl+Shift+C triggered')
63      },
64      {
65        key: 'Ctrl+R / F5',
66        description: 'Reload page',
67        action: () => window.location.reload()
68      },
69      {
70        key: 'Ctrl+Shift+R',
71        description: 'Hard reload',
72        action: () => console.log('Ctrl+Shift+R triggered')
73      },
74      {
75        key: 'F12',
76        description: 'DevTools',
77        action: () => console.log('F12 triggered')
78      },
79      {
80        key: 'Ctrl+← / Ctrl+→',
81        description: 'Navigate back/forward',
82        action: () => console.log('Ctrl+Arrow triggered')
83      },
84      {
85        key: 'Ctrl+0',
86        description: 'Reset zoom',
87        action: () => console.log('Ctrl+0 triggered')
88      },
89      {
90        key: 'Ctrl++ / Ctrl+-',
91        description: 'Zoom in/out',
92        action: () => console.log('Ctrl+Zoom triggered')
93      },
94      // Workspace pane mode
95      {
96        key: 'Ctrl+P',
97        description: 'Enter pane mode',
98        action: () => console.log('Ctrl+P triggered')
99      },
100      {
101        key: '→ / R (pane mode)',
102        description: 'Split pane right',
103        action: () => console.log('Split right triggered')
104      },
105      {
106        key: '← / L (pane mode)',
107        description: 'Split pane left',
108        action: () => console.log('Split left triggered')
109      },
110      {
111        key: '↑ / U (pane mode)',
112        description: 'Split pane up',
113        action: () => console.log('Split up triggered')
114      },
115      {
116        key: '↓ / D (pane mode)',
117        description: 'Split pane down',
118        action: () => console.log('Split down triggered')
119      },
120      {
121        key: 'X (pane mode)',
122        description: 'Close current pane',
123        action: () => console.log('Close pane triggered')
124      },
125      {
126        key: 'Enter (pane mode)',
127        description: 'Confirm action',
128        action: () => console.log('Confirm triggered')
129      },
130      {
131        key: 'Escape (pane mode)',
132        description: 'Exit pane mode',
133        action: () => console.log('Exit pane mode triggered')
134      },
135      // Workspace navigation
136      {
137        key: 'Alt+Arrow Keys',
138        description: 'Navigate between panes',
139        action: () => console.log('Navigate panes triggered')
140      }
141    ];
142    shortcutsLoading = false;
143  };
144
145  // Setup message bridge callbacks (once)
146  let callbacksInitialized = false;
147  const pendingRequests = new Map();
148
149  const initializeCallbacks = () => {
150    if (callbacksInitialized) return;
151
152    (window as any).__dumber_history_recent = (data: any[], requestId?: string) => {
153      const request = pendingRequests.get(requestId || 'default');
154      if (request) {
155        request.resolve(data);
156        pendingRequests.delete(requestId || 'default');
157      }
158    };
159
160    (window as any).__dumber_history_error = (error: string, requestId?: string) => {
161      const request = pendingRequests.get(requestId || 'default');
162      if (request) {
163        request.reject(new Error(error));
164        pendingRequests.delete(requestId || 'default');
165      }
166    };
167
168    // Omnibox suggestions callback - forward to injected script via DOM event bridge
169    let _pendingSuggestions: any[] | null = null;
170    (window as any).__dumber_omnibox_suggestions = (suggestions: any[]) => {
171      try {
172        console.log('🎯 [HOMEPAGE] Received omnibox suggestions:', suggestions?.length || 0, 'items');
173        // Dispatch an event that injected-world listener can consume
174        const evt = new CustomEvent('dumber:omnibox-suggestions', {
175          detail: { suggestions }
176        });
177        document.dispatchEvent(evt);
178        _pendingSuggestions = null; // clear cache on successful dispatch
179      } catch (err) {
180        console.warn('⚠️ [HOMEPAGE] Failed to dispatch suggestions event, caching:', err);
181        _pendingSuggestions = suggestions;
182      }
183    };
184
185    // If omnibox gets ready later, re-send any pending suggestions
186    document.addEventListener('dumber:omnibox-ready', () => {
187      if (_pendingSuggestions && Array.isArray(_pendingSuggestions)) {
188        try {
189          const evt = new CustomEvent('dumber:omnibox-suggestions', {
190            detail: { suggestions: _pendingSuggestions }
191          });
192          document.dispatchEvent(evt);
193          _pendingSuggestions = null;
194          console.log('🔄 [HOMEPAGE] Re-dispatched pending suggestions after omnibox-ready');
195        } catch (e) {
196          console.warn('⚠️ [HOMEPAGE] Failed to re-dispatch pending suggestions:', e);
197        }
198      }
199    });
200
201    callbacksInitialized = true;
202  };
203
204  const sendHistoryRequest = (type: string, params: any): Promise<any[]> => {
205    initializeCallbacks();
206
207    const requestId = `${type}_${Date.now()}_${Math.random()}`;
208
209    return new Promise((resolve, reject) => {
210      pendingRequests.set(requestId, { resolve, reject });
211
212      // Timeout after 10 seconds
213      setTimeout(() => {
214        if (pendingRequests.has(requestId)) {
215          pendingRequests.delete(requestId);
216          reject(new Error('Request timeout'));
217        }
218      }, 10000);
219
220      try {
221        const bridge = (window as any).webkit?.messageHandlers?.dumber;
222        if (bridge && typeof bridge.postMessage === 'function') {
223          bridge.postMessage(JSON.stringify({
224            ...params,
225            type,
226            requestId
227          }));
228        } else {
229          reject(new Error('WebKit message handler not available'));
230        }
231      } catch (error) {
232        pendingRequests.delete(requestId);
233        reject(error);
234      }
235    });
236  };
237
238  // Fetch recent history via message bridge
239  const fetchHistory = async () => {
240    try {
241      const data = await sendHistoryRequest('history_recent', {
242        limit: 20,
243        offset: 0
244      });
245
246      historyItems = Array.isArray(data) ? data : [];
247      historyOffset = historyItems.length;
248      hasMoreHistory = data.length === 20;
249    } catch (error) {
250      console.error('Error fetching history:', error);
251      historyItems = [];
252      hasMoreHistory = false;
253    } finally {
254      historyLoading = false;
255    }
256  };
257
258  // Load more history items for infinite scroll
259  const loadMoreHistory = async () => {
260    if (loadingMoreHistory || !hasMoreHistory) return;
261
262    loadingMoreHistory = true;
263    try {
264      const data = await sendHistoryRequest('history_recent', {
265        limit: 20,
266        offset: historyOffset
267      });
268
269      if (Array.isArray(data) && data.length > 0) {
270        historyItems = [...historyItems, ...data];
271        historyOffset += data.length;
272        hasMoreHistory = data.length === 20; // If we got full batch, there might be more
273      } else {
274        hasMoreHistory = false;
275      }
276    } catch (error) {
277      console.error('Error fetching more history:', error);
278      hasMoreHistory = false;
279    } finally {
280      loadingMoreHistory = false;
281    }
282  };
283
284  // Navigate to a URL when history item is clicked
285  const navigateTo = (url: string) => {
286    window.location.href = url;
287  };
288
289  // Delete a history entry
290  const deleteHistoryEntry = async (id: number, event: Event) => {
291    event.stopPropagation(); // Prevent navigation
292
293    // Add to deleting array for animation
294    if (!deletingIds.includes(id)) {
295      deletingIds = [...deletingIds, id];
296    }
297
298    try {
299      // Set up response handlers
300      (window as any).__dumber_history_deleted = async () => {
301        // Wait a bit for the fade animation to complete
302        await new Promise(resolve => setTimeout(resolve, 200));
303
304        // Reset pagination and refresh history after successful deletion
305        historyOffset = 0;
306        hasMoreHistory = true;
307        await fetchHistory();
308        await fetchStats();
309        await fetchTopVisited();
310
311        // Remove from deleting array after refresh
312        deletingIds = deletingIds.filter(i => i !== id);
313      };
314
315      (window as any).__dumber_history_error = (error: string) => {
316        console.error('Error deleting history entry:', error);
317        // Remove from deleting array if failed
318        deletingIds = deletingIds.filter(i => i !== id);
319      };
320
321      // Send message to Go backend
322      const bridge = (window as any).webkit?.messageHandlers?.dumber;
323      if (bridge && typeof bridge.postMessage === 'function') {
324        bridge.postMessage(JSON.stringify({
325          type: 'history_delete',
326          historyId: id.toString()
327        }));
328      } else {
329        throw new Error('WebKit message handler not available');
330      }
331    } catch (error) {
332      console.error('Error sending delete request:', error);
333      // Remove from deleting array if failed
334      deletingIds = deletingIds.filter(i => i !== id);
335    }
336  };
337
338  // Get domain from URL
339  const getDomain = (url: string): string => {
340    try {
341      return new URL(url).hostname;
342    } catch {
343      return url;
344    }
345  };
346
347  // Format relative time
348  const formatTime = (timestamp: string): string => {
349    try {
350      const date = new Date(timestamp);
351      const now = new Date();
352      const diff = now.getTime() - date.getTime();
353
354      const minutes = Math.floor(diff / (1000 * 60));
355      const hours = Math.floor(diff / (1000 * 60 * 60));
356      const days = Math.floor(diff / (1000 * 60 * 60 * 24));
357
358      if (minutes < 1) return 'Just now';
359      if (minutes < 60) return `${minutes}m ago`;
360      if (hours < 24) return `${hours}h ago`;
361      if (days < 7) return `${days}d ago`;
362
363      return date.toLocaleDateString();
364    } catch {
365      return '';
366    }
367  };
368
369  // Fetch history stats via message bridge
370  const fetchStats = async () => {
371    try {
372      // Set up response handler
373      (window as any).__dumber_history_stats = (data: any) => {
374        stats = data;
375        statsLoading = false;
376      };
377
378      (window as any).__dumber_history_error = (error: string) => {
379        console.error('Error fetching stats:', error);
380        stats = null;
381        statsLoading = false;
382      };
383
384      // Send message to Go backend
385      const bridge = (window as any).webkit?.messageHandlers?.dumber;
386      if (bridge && typeof bridge.postMessage === 'function') {
387        bridge.postMessage(JSON.stringify({
388          type: 'history_stats'
389        }));
390      } else {
391        throw new Error('WebKit message handler not available');
392      }
393    } catch (error) {
394      console.error('Error sending stats request:', error);
395      stats = null;
396      statsLoading = false;
397    }
398  };
399
400  // Fetch top visited sites via message bridge
401  const fetchTopVisited = async () => {
402    try {
403      // Set up response handler
404      (window as any).__dumber_history_search = (data: any[]) => {
405        // Sort by visit_count in descending order and take top 5
406        const sortedData = Array.isArray(data) ?
407          data.sort((a, b) => (b.visit_count || 0) - (a.visit_count || 0)).slice(0, 5) : [];
408        topVisited = sortedData;
409        topVisitedLoading = false;
410      };
411
412      (window as any).__dumber_history_error = (error: string) => {
413        console.error('Error fetching top visited:', error);
414        topVisited = [];
415        topVisitedLoading = false;
416      };
417
418      // Send message to Go backend
419      const bridge = (window as any).webkit?.messageHandlers?.dumber;
420      if (bridge && typeof bridge.postMessage === 'function') {
421        bridge.postMessage(JSON.stringify({
422          type: 'history_search',
423          q: '',
424          limit: 50  // Get more results to sort properly
425        }));
426      } else {
427        throw new Error('WebKit message handler not available');
428      }
429    } catch (error) {
430      console.error('Error sending top visited request:', error);
431      topVisited = [];
432      topVisitedLoading = false;
433    }
434  };
435
436  // Intersection Observer for infinite scroll
437  let historyScrollSentinel = $state<HTMLElement | null>(null);
438
439  const setupInfiniteScroll = () => {
440    if (!historyScrollSentinel) return;
441
442    const observer = new IntersectionObserver(
443      (entries) => {
444        const entry = entries[0];
445        if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) {
446          loadMoreHistory();
447        }
448      },
449      {
450        threshold: 0.1,
451        rootMargin: '50px'
452      }
453    );
454
455    observer.observe(historyScrollSentinel);
456
457    return () => observer.disconnect();
458  };
459
460  onMount(() => {
461    initializeShortcuts();
462    fetchHistory().then(() => {
463      // Setup infinite scroll after initial history is loaded
464      setTimeout(setupInfiniteScroll, 100);
465    });
466    fetchStats();
467    fetchTopVisited();
468  });
469
470</script>
471
472<div class="homepage-container">
473<div class="container">
474  <div class="history-section">
475    <h2 class="section-title">Recent History</h2>
476    <div class="history-list">
477      {#if historyLoading}
478        <div class="loading">Loading history...</div>
479      {:else if historyItems.length === 0}
480        <div class="empty-state">
481          <h3>No history yet</h3>
482          <p>Start browsing to see your recent history here.</p>
483        </div>
484      {:else}
485        {#each historyItems as item (item.url)}
486          <div
487            class="history-item"
488            class:deleting={isDeleting(item.id)}
489            role="button"
490            tabindex="0"
491            onclick={() => navigateTo(item.url)}
492            onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
493          >
494            <div class="history-line">
495              <div class="history-favicon-chip">
496                {#if item.favicon_url}
497                  <img
498                    src={item.favicon_url}
499                    alt=""
500                    class="history-favicon-img"
501                    onerror={(e) => {
502                      const target = e.target as HTMLImageElement;
503                      if (target) {
504                        target.style.display='none';
505                        const fallback = target.nextElementSibling as HTMLElement;
506                        if (fallback) fallback.style.display='block';
507                      }
508                    }}
509                    onload={(e) => {
510                      const target = e.target as HTMLImageElement;
511                      if (target) {
512                        const fallback = target.nextElementSibling as HTMLElement;
513                        if (fallback) fallback.style.display='none';
514                      }
515                    }}
516                  />
517                  <div class="history-favicon-fallback" style="display: none;">
518                    <!-- inline globe placeholder -->
519                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
520                      <circle cx="12" cy="12" r="10" />
521                      <path d="M2 12h20" />
522                    </svg>
523                  </div>
524                {:else}
525                  <div class="history-favicon-fallback">
526                    <!-- inline globe placeholder -->
527                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
528                      <circle cx="12" cy="12" r="10" />
529                      <path d="M2 12h20" />
530                    </svg>
531                  </div>
532                {/if}
533              </div>
534              <div class="history-title">{item.title || 'Untitled'}</div>
535              <div class="history-sep">•</div>
536              <div class="history-domain">{getDomain(item.url)}</div>
537              <div class="history-sep">•</div>
538              <div class="history-url">{item.url}</div>
539              <div class="history-time">{formatTime(item.last_visited)}</div>
540              <div
541                class="history-delete"
542                role="button"
543                tabindex="0"
544                onclick={(e) => deleteHistoryEntry(item.id, e)}
545                onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
546                title="Delete this entry"
547              >
548                <!-- inline delete placeholder -->
549                <span aria-hidden="true" style="display:inline-block;line-height:1;">×</span>
550              </div>
551            </div>
552          </div>
553        {/each}
554
555        <!-- Infinite scroll sentinel and loading indicator -->
556        {#if hasMoreHistory}
557          <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
558          {#if loadingMoreHistory}
559            <div class="loading-more">Loading more history...</div>
560          {/if}
561        {/if}
562      {/if}
563    </div>
564  </div>
565
566  <div class="shortcuts-section">
567    <h2 class="section-title">Keyboard Shortcuts</h2>
568    <div class="shortcuts-container">
569      {#if shortcutsLoading}
570        <div class="loading">Loading shortcuts...</div>
571      {:else}
572        <div class="shortcuts-card">
573          {#each shortcuts as shortcut, index (shortcut.key)}
574            <div class="shortcut-item">
575              <div class="shortcut-key-badge">{shortcut.key}</div>
576              <div class="shortcut-desc">{shortcut.description}</div>
577            </div>
578            {#if index < shortcuts.length - 1}
579              <div class="shortcut-separator"></div>
580            {/if}
581          {/each}
582        </div>
583      {/if}
584    </div>
585
586    <h2 class="section-title top-visited-title">Top 5 Most Visited</h2>
587    <div class="top-visited-container">
588      {#if topVisitedLoading}
589        <div class="loading">Loading top visited...</div>
590      {:else if topVisited.length === 0}
591        <div class="empty-state">
592          <p>No visit data available</p>
593        </div>
594      {:else}
595        <div class="top-visited-list">
596          {#each topVisited as item, index (item.url)}
597            <div
598              class="top-visited-item"
599              role="button"
600              tabindex="0"
601              onclick={() => navigateTo(item.url)}
602              onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
603            >
604              <div class="rank">{index + 1}</div>
605              <div class="site-info">
606                <div class="site-title">{item.title || 'Untitled'}</div>
607                <div class="site-domain">{getDomain(item.url)}</div>
608              </div>
609              <div class="visit-count">{item.visit_count}</div>
610            </div>
611          {/each}
612        </div>
613      {/if}
614    </div>
615
616    <h2 class="section-title stats-title">Statistics</h2>
617    <div class="stats-container">
618      {#if statsLoading}
619        <div class="loading">Loading stats...</div>
620      {:else if stats}
621        <div class="stats-grid">
622          <div class="stat-item">
623            <div class="stat-value">{stats.total_entries}</div>
624            <div class="stat-label">History Entries</div>
625          </div>
626        </div>
627      {:else}
628        <div class="empty-state">
629          <p>No statistics available</p>
630        </div>
631      {/if}
632    </div>
633  </div>
634</div>
635
636<Footer />
637</div>
638
639<style>
640  /* Remove default browser margins/padding on the page */
641  :global(html), :global(body) {
642    margin: 0;
643    padding: 0;
644    background: #1a1a1a;
645  }
646
647  /* Scoped homepage container to prevent global CSS conflicts */
648  .homepage-container {
649    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
650    background: #1a1a1a;
651    color: #ffffff;
652    min-height: 100vh;
653    overflow-x: hidden;
654    display: flex;
655    flex-direction: column;
656    margin: 0;
657    padding: 0;
658    box-sizing: border-box;
659  }
660
661  /* Reset styles for children, but scoped to homepage only */
662  .homepage-container * {
663    margin: 0;
664    padding: 0;
665    box-sizing: border-box;
666  }
667
668  .container {
669    flex: 1;
670    display: flex;
671    align-items: stretch;
672    max-width: 1200px;
673    margin: 0 auto;
674    width: 100%;
675    padding: 2rem;
676    gap: 2rem;
677    overflow-x: hidden;
678  }
679
680  .history-section {
681    flex: 2;
682    min-height: 0;
683    min-width: 0;
684    display: flex;
685    flex-direction: column;
686  }
687
688  .shortcuts-section {
689    flex: 1;
690    min-height: 0;
691    min-width: 0;
692  }
693
694  .section-title {
695    font-size: 1.5rem;
696    margin-bottom: 1rem;
697    color: #ffffff;
698    border-bottom: 2px solid #404040;
699    padding-bottom: 0.5rem;
700  }
701
702  .history-list {
703    overflow-y: auto;
704    overflow-x: hidden;
705    max-width: 100%;
706    max-height: calc(100vh - 8rem);
707  }
708
709  .history-item {
710    padding: 0.75rem;
711    margin-bottom: 0.5rem;
712    background: #2d2d2d;
713    border-radius: 6px;
714    cursor: pointer;
715    transition: all 0.2s ease-in-out;
716    border-left: 3px solid #404040;
717    overflow: hidden;
718    max-width: 100%;
719    position: relative;
720    opacity: 1;
721    transform: translateX(0);
722  }
723
724  .history-item:hover {
725    background: #3d3d3d;
726    border-left-color: #0066cc;
727  }
728
729  .history-item:hover .history-delete {
730    opacity: 1;
731    visibility: visible;
732  }
733
734  .history-item.deleting {
735    opacity: 0;
736    transform: translateX(-20px);
737    pointer-events: none;
738  }
739
740  .history-line {
741    display: flex;
742    gap: 0.5rem;
743    white-space: nowrap;
744    align-items: center;
745    overflow: hidden;
746    width: 100%;
747    min-width: 0;
748  }
749
750  .history-favicon-chip {
751    flex: 0 0 20px;
752    width: 20px;
753    height: 20px;
754    border-radius: 50%;
755    background: #ccc;
756    border: 1px solid rgba(0,0,0,.12);
757    box-shadow: 0 1px 2px rgba(0,0,0,.12);
758    display: flex;
759    align-items: center;
760    justify-content: center;
761  }
762
763  .history-favicon-img {
764    width: 16px;
765    height: 16px;
766    filter: brightness(1.06) contrast(1.03);
767    image-rendering: -webkit-optimize-contrast;
768  }
769
770  .history-favicon-fallback {
771    font-size: 12px;
772    display: flex;
773    align-items: center;
774    justify-content: center;
775    color: #888;
776  }
777
778  .history-title {
779    font-size: 0.95rem;
780    color: #e6e6e6;
781    flex: 0 0 auto;
782  }
783
784  .history-domain {
785    font-size: 0.9rem;
786    color: #a5a5a5;
787    flex: 0 0 auto;
788  }
789
790  .history-sep {
791    color: #666;
792    flex: 0 0 auto;
793  }
794
795  .history-url {
796    font-size: 0.9rem;
797    color: #7a7a7a;
798    flex: 1 1 auto;
799    min-width: 0;
800    overflow: hidden;
801    text-overflow: ellipsis;
802    -webkit-mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%);
803    mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%);
804  }
805
806  .history-time {
807    font-size: 0.85rem;
808    color: #666;
809    flex: 0 0 auto;
810    margin-left: auto;
811  }
812
813  .history-delete {
814    position: absolute;
815    right: 0.75rem;
816    top: 50%;
817    transform: translateY(-50%);
818    opacity: 0;
819    visibility: hidden;
820    transition: opacity 0.2s, visibility 0.2s, transform 0.2s;
821    cursor: pointer;
822    background: #1a1a1a;
823    border-radius: 3px;
824    padding: 0.25rem;
825    border: 1px solid #404040;
826    display: flex;
827    align-items: center;
828    justify-content: center;
829    width: 2rem;
830    height: 2rem;
831    color: #888;
832  }
833
834  .history-delete:hover {
835    background: #ff4444;
836    border-color: #ff6666;
837    color: #fff;
838    transform: translateY(-50%) scale(1.1);
839  }
840
841  .shortcuts-container {
842    /* Remove height limit to show all shortcuts */
843  }
844
845  .shortcuts-card {
846    background: #2d2d2d;
847    border-radius: 8px;
848    border: 1px solid #404040;
849    padding: 1rem;
850  }
851
852  .shortcut-item {
853    display: flex;
854    align-items: center;
855    gap: 0.75rem;
856    padding: 0.5rem 0;
857    transition: all 0.2s ease;
858  }
859
860  .shortcut-item:hover {
861    background: rgba(255, 255, 255, 0.02);
862    border-radius: 4px;
863    padding: 0.5rem;
864    margin: 0 -0.5rem;
865  }
866
867  .shortcut-separator {
868    height: 1px;
869    background: #404040;
870    margin: 0.25rem 0;
871  }
872
873  .shortcut-key-badge {
874    background: linear-gradient(135deg, #0066cc, #004499);
875    color: #ffffff;
876    padding: 0.25rem 0.5rem;
877    border-radius: 4px;
878    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
879    font-size: 0.75rem;
880    font-weight: 600;
881    white-space: nowrap;
882    box-shadow: 0 1px 2px rgba(0, 102, 204, 0.2);
883    border: 1px solid rgba(255, 255, 255, 0.1);
884    min-width: fit-content;
885  }
886
887  .shortcut-desc {
888    color: #e6e6e6;
889    font-size: 0.9rem;
890    flex: 1;
891  }
892
893  .stats-title {
894    margin-top: 2rem;
895  }
896
897  .stats-container {
898    margin-top: 1rem;
899  }
900
901  .stats-grid {
902    display: flex;
903    gap: 1rem;
904  }
905
906  .stat-item {
907    background: #2d2d2d;
908    border-radius: 6px;
909    padding: 1rem;
910    text-align: center;
911    flex: 1;
912    border: 1px solid #404040;
913  }
914
915  .stat-value {
916    font-size: 1.5rem;
917    font-weight: bold;
918    color: #0066cc;
919    margin-bottom: 0.25rem;
920  }
921
922  .stat-label {
923    font-size: 0.8rem;
924    color: #888;
925    text-transform: uppercase;
926    letter-spacing: 0.5px;
927  }
928
929  .top-visited-title {
930    margin-top: 2rem;
931  }
932
933  .top-visited-container {
934    margin-top: 1rem;
935  }
936
937  .top-visited-list {
938    display: flex;
939    flex-direction: column;
940    gap: 0.5rem;
941  }
942
943  .top-visited-item {
944    display: flex;
945    align-items: center;
946    padding: 0.75rem;
947    background: #2d2d2d;
948    border-radius: 6px;
949    cursor: pointer;
950    transition: background 0.2s;
951    border: 1px solid #404040;
952    gap: 1rem;
953  }
954
955  .top-visited-item:hover {
956    background: #3d3d3d;
957    border-color: #0066cc;
958  }
959
960  .rank {
961    font-weight: bold;
962    color: #0066cc;
963    font-size: 1.1rem;
964    min-width: 1.5rem;
965    text-align: center;
966  }
967
968  .site-info {
969    flex: 1;
970    min-width: 0;
971  }
972
973  .site-title {
974    font-size: 0.9rem;
975    color: #e6e6e6;
976    margin-bottom: 0.2rem;
977    white-space: nowrap;
978    overflow: hidden;
979    text-overflow: ellipsis;
980  }
981
982  .site-domain {
983    font-size: 0.8rem;
984    color: #888;
985  }
986
987  .visit-count {
988    font-size: 0.85rem;
989    color: #0066cc;
990    font-weight: bold;
991    min-width: 2rem;
992    text-align: center;
993  }
994
995  .loading {
996    padding: 2rem;
997    text-align: center;
998    color: #888;
999  }
1000
1001  .scroll-sentinel {
1002    height: 1px;
1003    width: 100%;
1004  }
1005
1006  .loading-more {
1007    padding: 1rem;
1008    text-align: center;
1009    color: #888;
1010    font-size: 0.9rem;
1011  }
1012
1013  .empty-state {
1014    padding: 3rem 2rem;
1015    text-align: center;
1016    color: #666;
1017  }
1018
1019  .empty-state h3 {
1020    margin-bottom: 1rem;
1021    color: #888;
1022  }
1023
1024  @media (max-width: 768px) {
1025    .container {
1026      flex-direction: column;
1027      padding: 1rem;
1028    }
1029  }
1030</style>

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