v0.8.0 0fa6785

Homepage.svelte RawHistoryFolder

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