v0.10.0 0f4e9eb

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  const MAX_QUICK_ACCESS_ITEMS = 9;
42  type ThemeMode = "light" | "dark";
43  const THEME_STORAGE_KEY = "dumber.theme";
44  let themeMode = $state<ThemeMode>("dark");
45  let themeObserver: MutationObserver | null = null;
46  let historyHeaderRef = $state<HTMLElement | null>(null);
47  let historyBodyRef = $state<HTMLElement | null>(null);
48  let insightsPanelRef = $state<HTMLElement | null>(null);
49  let resizeSyncRaf = 0;
50
51  const syncHistoryPanelSize = () => {
52    if (!historyBodyRef || !historyHeaderRef || !insightsPanelRef) return;
53
54    if (window.innerWidth <= 960) {
55      historyBodyRef.style.maxHeight = "";
56      return;
57    }
58
59    const insightsHeight = insightsPanelRef.getBoundingClientRect().height;
60    if (!insightsHeight || Number.isNaN(insightsHeight)) {
61      historyBodyRef.style.maxHeight = "";
62      return;
63    }
64
65    const headerHeight = historyHeaderRef.getBoundingClientRect().height;
66    const bodyStyles = getComputedStyle(historyBodyRef);
67    const paddingTop = parseFloat(bodyStyles.paddingTop) || 0;
68    const paddingBottom = parseFloat(bodyStyles.paddingBottom) || 0;
69    const maxHeight = insightsHeight - headerHeight - paddingTop - paddingBottom;
70
71    if (maxHeight > 0) {
72      historyBodyRef.style.maxHeight = `${maxHeight}px`;
73    } else {
74      historyBodyRef.style.maxHeight = "";
75    }
76  };
77
78  const scheduleHistoryPanelSync = () => {
79    if (resizeSyncRaf) cancelAnimationFrame(resizeSyncRaf);
80    resizeSyncRaf = requestAnimationFrame(() => {
81      resizeSyncRaf = 0;
82      syncHistoryPanelSize();
83    });
84  };
85
86  // Function for checking if an item is being deleted
87  const isDeleting = (id: number) => deletingIds.includes(id);
88
89  // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
90  const initializeShortcuts = () => {
91    shortcuts = [
92      // Browser shortcuts
93      {
94        key: 'Ctrl+L',
95        description: 'Focus address bar',
96        action: () => console.log('Ctrl+L triggered')
97      },
98      {
99        key: 'Ctrl+F',
100        description: 'Find in page',
101        action: () => console.log('Ctrl+F triggered')
102      },
103      {
104        key: 'Ctrl+Shift+C',
105        description: 'Copy URL',
106        action: () => console.log('Ctrl+Shift+C triggered')
107      },
108      {
109        key: 'Ctrl+R / F5',
110        description: 'Reload page',
111        action: () => window.location.reload()
112      },
113      {
114        key: 'Ctrl+Shift+R',
115        description: 'Hard reload',
116        action: () => console.log('Ctrl+Shift+R triggered')
117      },
118      {
119        key: 'F12',
120        description: 'DevTools',
121        action: () => console.log('F12 triggered')
122      },
123      {
124        key: 'Ctrl+← / Ctrl+→',
125        description: 'Navigate back/forward',
126        action: () => console.log('Ctrl+Arrow triggered')
127      },
128      {
129        key: 'Ctrl+0',
130        description: 'Reset zoom',
131        action: () => console.log('Ctrl+0 triggered')
132      },
133      {
134        key: 'Ctrl++ / Ctrl+-',
135        description: 'Zoom in/out',
136        action: () => console.log('Ctrl+Zoom triggered')
137      },
138      // Workspace pane mode
139      {
140        key: 'Ctrl+P',
141        description: 'Enter pane mode',
142        action: () => console.log('Ctrl+P triggered')
143      },
144      {
145        key: '→ / R (pane mode)',
146        description: 'Split pane right',
147        action: () => console.log('Split right triggered')
148      },
149      {
150        key: '← / L (pane mode)',
151        description: 'Split pane left',
152        action: () => console.log('Split left triggered')
153      },
154      {
155        key: '↑ / U (pane mode)',
156        description: 'Split pane up',
157        action: () => console.log('Split up triggered')
158      },
159      {
160        key: '↓ / D (pane mode)',
161        description: 'Split pane down',
162        action: () => console.log('Split down triggered')
163      },
164      {
165        key: 'X (pane mode)',
166        description: 'Close current pane',
167        action: () => console.log('Close pane triggered')
168      },
169      {
170        key: 'Enter (pane mode)',
171        description: 'Confirm action',
172        action: () => console.log('Confirm triggered')
173      },
174      {
175        key: 'Escape (pane mode)',
176        description: 'Exit pane mode',
177        action: () => console.log('Exit pane mode triggered')
178      },
179      // Workspace navigation
180      {
181        key: 'Alt+Arrow Keys',
182        description: 'Navigate between panes',
183        action: () => console.log('Navigate panes triggered')
184      }
185    ];
186    shortcutsLoading = false;
187  };
188
189  // Setup message bridge callbacks (once)
190  let callbacksInitialized = false;
191  const pendingRequests = new Map();
192
193  const initializeCallbacks = () => {
194    if (callbacksInitialized) return;
195
196    (window as any).__dumber_history_recent = (data: any[], requestId?: string) => {
197      const request = pendingRequests.get(requestId || 'default');
198      if (request) {
199        request.resolve(data);
200        pendingRequests.delete(requestId || 'default');
201      }
202    };
203
204    (window as any).__dumber_history_error = (error: string, requestId?: string) => {
205      const request = pendingRequests.get(requestId || 'default');
206      if (request) {
207        request.reject(new Error(error));
208        pendingRequests.delete(requestId || 'default');
209      }
210    };
211
212    // Omnibox suggestions callback - forward to injected script via DOM event bridge
213    let _pendingSuggestions: any[] | null = null;
214    (window as any).__dumber_omnibox_suggestions = (suggestions: any[]) => {
215      try {
216        console.log('🎯 [HOMEPAGE] Received omnibox suggestions:', suggestions?.length || 0, 'items');
217        // Dispatch an event that injected-world listener can consume
218        const evt = new CustomEvent('dumber:omnibox-suggestions', {
219          detail: { suggestions }
220        });
221        document.dispatchEvent(evt);
222        _pendingSuggestions = null; // clear cache on successful dispatch
223      } catch (err) {
224        console.warn('⚠️ [HOMEPAGE] Failed to dispatch suggestions event, caching:', err);
225        _pendingSuggestions = suggestions;
226      }
227    };
228
229    // If omnibox gets ready later, re-send any pending suggestions
230    document.addEventListener('dumber:omnibox-ready', () => {
231      if (_pendingSuggestions && Array.isArray(_pendingSuggestions)) {
232        try {
233          const evt = new CustomEvent('dumber:omnibox-suggestions', {
234            detail: { suggestions: _pendingSuggestions }
235          });
236          document.dispatchEvent(evt);
237          _pendingSuggestions = null;
238          console.log('🔄 [HOMEPAGE] Re-dispatched pending suggestions after omnibox-ready');
239        } catch (e) {
240          console.warn('⚠️ [HOMEPAGE] Failed to re-dispatch pending suggestions:', e);
241        }
242      }
243    });
244
245    callbacksInitialized = true;
246  };
247
248  const sendHistoryRequest = (type: string, params: any): Promise<any[]> => {
249    initializeCallbacks();
250
251    const requestId = `${type}_${Date.now()}_${Math.random()}`;
252
253    return new Promise((resolve, reject) => {
254      pendingRequests.set(requestId, { resolve, reject });
255
256      // Timeout after 10 seconds
257      setTimeout(() => {
258        if (pendingRequests.has(requestId)) {
259          pendingRequests.delete(requestId);
260          reject(new Error('Request timeout'));
261        }
262      }, 10000);
263
264      try {
265        const bridge = (window as any).webkit?.messageHandlers?.dumber;
266        if (bridge && typeof bridge.postMessage === 'function') {
267          bridge.postMessage(JSON.stringify({
268            ...params,
269            type,
270            requestId
271          }));
272        } else {
273          reject(new Error('WebKit message handler not available'));
274        }
275      } catch (error) {
276        pendingRequests.delete(requestId);
277        reject(error);
278      }
279    });
280  };
281
282  // Fetch recent history via message bridge
283  const fetchHistory = async () => {
284    try {
285      const data = await sendHistoryRequest('history_recent', {
286        limit: 20,
287        offset: 0
288      });
289
290      historyItems = Array.isArray(data) ? data : [];
291      historyOffset = historyItems.length;
292      hasMoreHistory = data.length === 20;
293    } catch (error) {
294      console.error('Error fetching history:', error);
295      historyItems = [];
296      hasMoreHistory = false;
297    } finally {
298      historyLoading = false;
299    }
300  };
301
302  // Load more history items for infinite scroll
303  const loadMoreHistory = async () => {
304    if (loadingMoreHistory || !hasMoreHistory) return;
305
306    loadingMoreHistory = true;
307    try {
308      const data = await sendHistoryRequest('history_recent', {
309        limit: 20,
310        offset: historyOffset
311      });
312
313      if (Array.isArray(data) && data.length > 0) {
314        historyItems = [...historyItems, ...data];
315        historyOffset += data.length;
316        hasMoreHistory = data.length === 20; // If we got full batch, there might be more
317      } else {
318        hasMoreHistory = false;
319      }
320    } catch (error) {
321      console.error('Error fetching more history:', error);
322      hasMoreHistory = false;
323    } finally {
324      loadingMoreHistory = false;
325    }
326  };
327
328  // Navigate to a URL when history item is clicked
329  const navigateTo = (url: string) => {
330    window.location.href = url;
331  };
332
333  // Delete a history entry
334  const deleteHistoryEntry = async (id: number, event: Event) => {
335    event.stopPropagation(); // Prevent navigation
336
337    // Add to deleting array for animation
338    if (!deletingIds.includes(id)) {
339      deletingIds = [...deletingIds, id];
340    }
341
342    try {
343      // Set up response handlers
344      (window as any).__dumber_history_deleted = async () => {
345        // Wait a bit for the fade animation to complete
346        await new Promise(resolve => setTimeout(resolve, 200));
347
348        // Reset pagination and refresh history after successful deletion
349        historyOffset = 0;
350        hasMoreHistory = true;
351        await fetchHistory();
352        await fetchStats();
353        await fetchTopVisited();
354
355        // Remove from deleting array after refresh
356        deletingIds = deletingIds.filter(i => i !== id);
357      };
358
359      (window as any).__dumber_history_error = (error: string) => {
360        console.error('Error deleting history entry:', error);
361        // Remove from deleting array if failed
362        deletingIds = deletingIds.filter(i => i !== id);
363      };
364
365      // Send message to Go backend
366      const bridge = (window as any).webkit?.messageHandlers?.dumber;
367      if (bridge && typeof bridge.postMessage === 'function') {
368        bridge.postMessage(JSON.stringify({
369          type: 'history_delete',
370          historyId: id.toString()
371        }));
372      } else {
373        throw new Error('WebKit message handler not available');
374      }
375    } catch (error) {
376      console.error('Error sending delete request:', error);
377      // Remove from deleting array if failed
378      deletingIds = deletingIds.filter(i => i !== id);
379    }
380  };
381
382  // Get domain from URL
383  const getDomain = (url: string): string => {
384    try {
385      return new URL(url).hostname;
386    } catch {
387      return url;
388    }
389  };
390
391  const getDisplayTitle = (item: HistoryItem): string => {
392    if (!item) return '';
393    const trimmed = item.title?.trim();
394    if (trimmed) return trimmed;
395    const domain = getDomain(item.url);
396    return domain || item.url;
397  };
398
399  // Format relative time
400  const formatTime = (timestamp: string): string => {
401    try {
402      const date = new Date(timestamp);
403      const now = new Date();
404      const diff = now.getTime() - date.getTime();
405
406      const minutes = Math.floor(diff / (1000 * 60));
407      const hours = Math.floor(diff / (1000 * 60 * 60));
408      const days = Math.floor(diff / (1000 * 60 * 60 * 24));
409
410      if (minutes < 1) return 'Just now';
411      if (minutes < 60) return `${minutes}m ago`;
412      if (hours < 24) return `${hours}h ago`;
413      if (days < 7) return `${days}d ago`;
414
415      return date.toLocaleDateString();
416    } catch {
417      return '';
418    }
419  };
420
421  const formatCalendarDate = (timestamp?: string): string => {
422    if (!timestamp) return '—';
423    try {
424      const date = new Date(timestamp);
425      return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
426    } catch {
427      return '—';
428    }
429  };
430
431  const formatVisitLabel = (count: number): string => {
432    if (!count || count <= 0) return 'Open';
433    if (count >= 1000) {
434      const value = count / 1000;
435      const formatted = value >= 10 ? Math.round(value).toString() : value.toFixed(1).replace(/\.0$/, '');
436      return `${formatted}k visits`;
437    }
438    return `${count} ${count === 1 ? 'visit' : 'visits'}`;
439  };
440
441  const buildQuickAccess = (): HistoryItem[] => {
442    const picks: HistoryItem[] = [];
443    const seen = new Set<string>();
444
445    const pushItem = (item: HistoryItem | null | undefined) => {
446      if (!item) return;
447      const domain = getDomain(item.url);
448      const key = domain || item.url;
449      if (seen.has(key)) return;
450      seen.add(key);
451      picks.push(item);
452    };
453
454    for (const item of topVisited ?? []) {
455      pushItem(item);
456      if (picks.length >= MAX_QUICK_ACCESS_ITEMS) return picks;
457    }
458
459    for (const item of historyItems ?? []) {
460      pushItem(item);
461      if (picks.length >= MAX_QUICK_ACCESS_ITEMS) break;
462    }
463
464    return picks;
465  };
466
467  let quickAccess = $derived<HistoryItem[]>(buildQuickAccess());
468  let quickAccessLoading = $derived(topVisitedLoading || historyLoading);
469
470  const persistTheme = (mode: ThemeMode) => {
471    try {
472      localStorage.setItem(THEME_STORAGE_KEY, mode);
473    } catch (error) {
474      console.warn("[homepage] Failed to persist theme preference", error);
475    }
476  };
477
478  const applyTheme = (mode: ThemeMode) => {
479    themeMode = mode;
480
481    const manager = (window as any).__dumber_color_scheme_manager as
482      | { setUserPreference?: (theme: ThemeMode) => void }
483      | undefined;
484
485    if (manager?.setUserPreference) {
486      manager.setUserPreference(mode);
487      return;
488    }
489
490    if (window.__dumber_setTheme) {
491      window.__dumber_setTheme(mode);
492      persistTheme(mode);
493      return;
494    }
495
496    if (mode === "dark") {
497      document.documentElement.classList.add("dark");
498    } else {
499      document.documentElement.classList.remove("dark");
500    }
501    persistTheme(mode);
502  };
503
504  const toggleTheme = () => {
505    const nextMode = themeMode === "dark" ? "light" : "dark";
506    applyTheme(nextMode);
507  };
508
509  // Fetch history stats via message bridge
510  const fetchStats = async () => {
511    try {
512      // Set up response handler
513      (window as any).__dumber_history_stats = (data: any) => {
514        stats = data;
515        statsLoading = false;
516      };
517
518      (window as any).__dumber_history_error = (error: string) => {
519        console.error('Error fetching stats:', error);
520        stats = null;
521        statsLoading = false;
522      };
523
524      // Send message to Go backend
525      const bridge = (window as any).webkit?.messageHandlers?.dumber;
526      if (bridge && typeof bridge.postMessage === 'function') {
527        bridge.postMessage(JSON.stringify({
528          type: 'history_stats'
529        }));
530      } else {
531        throw new Error('WebKit message handler not available');
532      }
533    } catch (error) {
534      console.error('Error sending stats request:', error);
535      stats = null;
536      statsLoading = false;
537    }
538  };
539
540  // Fetch top visited sites via message bridge
541  const fetchTopVisited = async () => {
542    try {
543      // Set up response handler
544      (window as any).__dumber_history_search = (data: any[]) => {
545        // Sort by visit_count in descending order and take top 5
546        const sortedData = Array.isArray(data) ?
547          data.sort((a, b) => (b.visit_count || 0) - (a.visit_count || 0)).slice(0, 5) : [];
548        topVisited = sortedData;
549        topVisitedLoading = false;
550      };
551
552      (window as any).__dumber_history_error = (error: string) => {
553        console.error('Error fetching top visited:', error);
554        topVisited = [];
555        topVisitedLoading = false;
556      };
557
558      // Send message to Go backend
559      const bridge = (window as any).webkit?.messageHandlers?.dumber;
560      if (bridge && typeof bridge.postMessage === 'function') {
561        bridge.postMessage(JSON.stringify({
562          type: 'history_search',
563          q: '',
564          limit: 50  // Get more results to sort properly
565        }));
566      } else {
567        throw new Error('WebKit message handler not available');
568      }
569    } catch (error) {
570      console.error('Error sending top visited request:', error);
571      topVisited = [];
572      topVisitedLoading = false;
573    }
574  };
575
576  // Intersection Observer for infinite scroll
577  let historyScrollSentinel = $state<HTMLElement | null>(null);
578
579  const setupInfiniteScroll = () => {
580    if (!historyScrollSentinel) return;
581
582    const observer = new IntersectionObserver(
583      (entries) => {
584        const entry = entries[0];
585        if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) {
586          loadMoreHistory();
587        }
588      },
589      {
590        threshold: 0.1,
591        rootMargin: '50px'
592      }
593    );
594
595    observer.observe(historyScrollSentinel);
596
597    return () => observer.disconnect();
598  };
599
600  onMount(() => {
601    initializeShortcuts();
602    fetchHistory().then(() => {
603      // Setup infinite scroll after initial history is loaded
604      setTimeout(setupInfiniteScroll, 100);
605    });
606    fetchStats();
607    fetchTopVisited();
608
609    // Theme synchronization
610    const syncThemeState = () => {
611      themeMode = document.documentElement.classList.contains("dark")
612        ? "dark"
613        : "light";
614    };
615    syncThemeState();
616    themeObserver = new MutationObserver(syncThemeState);
617    themeObserver.observe(document.documentElement, {
618      attributes: true,
619      attributeFilter: ["class"],
620    });
621
622    window.addEventListener('resize', scheduleHistoryPanelSync);
623
624    return () => {
625      themeObserver?.disconnect();
626      themeObserver = null;
627      window.removeEventListener('resize', scheduleHistoryPanelSync);
628      if (resizeSyncRaf) {
629        cancelAnimationFrame(resizeSyncRaf);
630        resizeSyncRaf = 0;
631      }
632    };
633  });
634
635  $effect(() => {
636    historyItems;
637    topVisited;
638    stats;
639    shortcuts;
640    scheduleHistoryPanelSync();
641  });
642
643  $effect(() => {
644    historyHeaderRef;
645    historyBodyRef;
646    insightsPanelRef;
647    scheduleHistoryPanelSync();
648  });
649
650</script>
651
652<svelte:head>
653  <title>Dumber Browser - Homepage</title>
654  <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" />
655</svelte:head>
656
657<div class="homepage-shell">
658  <div class="homepage-content">
659    <div class="top-bar">
660      <button
661        class="theme-toggle-button"
662        type="button"
663        aria-pressed={themeMode === "dark"}
664        title={themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
665        onclick={toggleTheme}
666      >
667        <span class="sr-only">
668          {themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
669        </span>
670        {#if themeMode === "dark"}
671          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
672            <path
673              fill="currentColor"
674              d="M21 12.79A9 9 0 0111.21 3 7 7 0 0012 17a7 7 0 009-4.21z"
675            />
676          </svg>
677        {:else}
678          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
679            <circle cx="12" cy="12" r="5" fill="currentColor" />
680            <g stroke="currentColor" stroke-width="2" stroke-linecap="round">
681              <line x1="12" y1="1" x2="12" y2="4" />
682              <line x1="12" y1="20" x2="12" y2="23" />
683              <line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
684              <line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
685              <line x1="1" y1="12" x2="4" y2="12" />
686              <line x1="20" y1="12" x2="23" y2="12" />
687              <line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
688              <line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
689            </g>
690          </svg>
691        {/if}
692      </button>
693    </div>
694
695    <section class="hero-panel brutal-panel">
696      <div class="hero-heading">
697        <h1 class="hero-title">Jump back in</h1>
698        <p class="hero-subtitle">Direct access to the sites you visit the most, plus what you just explored.</p>
699      </div>
700      <div class="quick-access-wrapper">
701        {#if quickAccessLoading}
702          <div class="loading quick-access-loading">Preparing your shortcuts...</div>
703        {:else if quickAccess.length === 0}
704          <div class="empty-state quick-access-empty">
705            <h3>No quick links yet</h3>
706            <p>Browse a few sites and we’ll surface your frequent destinations here.</p>
707          </div>
708        {:else}
709          <div class="quick-access-grid">
710            {#each quickAccess as item (item.id)}
711              <div
712                class="quick-access-item"
713                role="button"
714                tabindex="0"
715                onclick={() => navigateTo(item.url)}
716                onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
717              >
718                <div class="quick-access-favicon">
719                  {#if item.favicon_url}
720                    <img
721                      src={item.favicon_url}
722                      alt=""
723                      class="quick-access-favicon-img"
724                      onerror={(e) => {
725                        const target = e.target as HTMLImageElement;
726                        if (target) {
727                          target.style.display = 'none';
728                          const fallback = target.nextElementSibling as HTMLElement;
729                          if (fallback) fallback.style.display = 'flex';
730                        }
731                      }}
732                      onload={(e) => {
733                        const target = e.target as HTMLImageElement;
734                        if (target) {
735                          const fallback = target.nextElementSibling as HTMLElement;
736                          if (fallback) fallback.style.display = 'none';
737                        }
738                      }}
739                    />
740                    <div class="quick-access-fallback" style="display: none;">
741                      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
742                        <circle cx="12" cy="12" r="10" />
743                        <path d="M2 12h20" />
744                      </svg>
745                    </div>
746                  {:else}
747                    <div class="quick-access-fallback">
748                      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
749                        <circle cx="12" cy="12" r="10" />
750                        <path d="M2 12h20" />
751                      </svg>
752                    </div>
753                  {/if}
754                </div>
755                <div class="quick-access-meta">
756                  <div class="quick-access-title">{getDisplayTitle(item)}</div>
757                  <div class="quick-access-domain">{getDomain(item.url)}</div>
758                </div>
759                <div class="quick-access-visits">{formatVisitLabel(item.visit_count)}</div>
760              </div>
761            {/each}
762          </div>
763        {/if}
764      </div>
765    </section>
766
767    <div class="main-panels">
768      <section class="history-panel brutal-panel">
769        <div class="panel-header" bind:this={historyHeaderRef}>
770          <h2 class="panel-title">Recent History</h2>
771          <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>
772        </div>
773        <div class="panel-body history-body" bind:this={historyBodyRef}>
774          {#if historyLoading}
775            <div class="loading">Loading history...</div>
776          {:else if historyItems.length === 0}
777            <div class="empty-state">
778              <h3>No history yet</h3>
779              <p>Start browsing to see your recent history here.</p>
780            </div>
781          {:else}
782            <div class="history-list">
783              {#each historyItems as item (item.id)}
784                <div
785                  class="history-item"
786                  class:deleting={isDeleting(item.id)}
787                  role="button"
788                  tabindex="0"
789                  onclick={() => navigateTo(item.url)}
790                  onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
791                >
792                  <div class="history-item-leading">
793                    {#if item.favicon_url}
794                      <img
795                        src={item.favicon_url}
796                        alt=""
797                        class="history-favicon-img"
798                        onerror={(e) => {
799                          const target = e.target as HTMLImageElement;
800                          if (target) {
801                            target.style.display = 'none';
802                            const fallback = target.nextElementSibling as HTMLElement;
803                            if (fallback) fallback.style.display = 'flex';
804                          }
805                        }}
806                        onload={(e) => {
807                          const target = e.target as HTMLImageElement;
808                          if (target) {
809                            const fallback = target.nextElementSibling as HTMLElement;
810                            if (fallback) fallback.style.display = 'none';
811                          }
812                        }}
813                      />
814                      <div class="history-favicon-fallback" style="display: none;">
815                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
816                          <circle cx="12" cy="12" r="10" />
817                          <path d="M2 12h20" />
818                        </svg>
819                      </div>
820                    {:else}
821                      <div class="history-favicon-fallback">
822                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
823                          <circle cx="12" cy="12" r="10" />
824                          <path d="M2 12h20" />
825                        </svg>
826                      </div>
827                    {/if}
828                  </div>
829                  <div class="history-item-content">
830                    <div class="history-item-top">
831                      <div class="history-title">{getDisplayTitle(item)}</div>
832                      <div class="history-time">{formatTime(item.last_visited)}</div>
833                    </div>
834                    <div class="history-item-bottom">
835                      <div class="history-domain">{getDomain(item.url)}</div>
836                      <div class="history-url">{item.url}</div>
837                    </div>
838                  </div>
839                  <div
840                    class="history-delete"
841                    role="button"
842                    tabindex="0"
843                    onclick={(e) => deleteHistoryEntry(item.id, e)}
844                    onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
845                    title="Delete this entry"
846                  >
847                    <span aria-hidden="true">×</span>
848                  </div>
849                </div>
850              {/each}
851
852              {#if hasMoreHistory}
853                <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
854                {#if loadingMoreHistory}
855                  <div class="loading-more">Loading more history...</div>
856                {/if}
857              {/if}
858            </div>
859          {/if}
860        </div>
861      </section>
862
863      <section class="insights-panel brutal-panel" bind:this={insightsPanelRef}>
864        <div class="panel-header">
865          <h2 class="panel-title">Usage Insights</h2>
866          <p class="panel-subtitle">Your browsing patterns at a glance.</p>
867        </div>
868        <div class="panel-body insights-body">
869            <div class="insight-block">
870              <h3 class="block-title">Top sites</h3>
871              {#if topVisitedLoading}
872                <div class="loading">Loading usage insights...</div>
873              {:else if topVisited.length === 0}
874                <div class="empty-state compact">
875                  <p>No visit data available</p>
876                </div>
877              {:else}
878                <div class="insights-list">
879                  {#each topVisited as item, index (item.id)}
880                    <div
881                      class="insight-item"
882                      role="button"
883                      tabindex="0"
884                      onclick={() => navigateTo(item.url)}
885                      onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
886                    >
887                      <div class="insight-rank">{index + 1}</div>
888                      <div class="insight-body">
889                        <div class="insight-title">{getDisplayTitle(item)}</div>
890                        <div class="insight-domain">{getDomain(item.url)}</div>
891                      </div>
892                      <div class="insight-visits">{formatVisitLabel(item.visit_count)}</div>
893                    </div>
894                  {/each}
895                </div>
896              {/if}
897            </div>
898
899            <div class="insight-block">
900              <h3 class="block-title">History snapshot</h3>
901              {#if statsLoading}
902                <div class="loading">Loading stats...</div>
903              {:else if stats}
904                <div class="stats-grid">
905                  <div class="stat-item">
906                    <div class="stat-value">{stats.total_entries}</div>
907                    <div class="stat-label">Entries Stored</div>
908                  </div>
909                  <div class="stat-item">
910                    <div class="stat-value">{stats.recent_count}</div>
911                    <div class="stat-label">Recent Window</div>
912                  </div>
913                  {#if stats.newest_entry}
914                    <div class="stat-item">
915                      <div class="stat-value">{formatTime(stats.newest_entry)}</div>
916                      <div class="stat-label">Newest Visit</div>
917                    </div>
918                  {/if}
919                  {#if stats.oldest_entry}
920                    <div class="stat-item">
921                      <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
922                      <div class="stat-label">Oldest Entry Loaded</div>
923                    </div>
924                  {/if}
925                </div>
926              {:else}
927                <div class="empty-state compact">
928                  <p>No statistics available</p>
929                </div>
930              {/if}
931            </div>
932        </div>
933      </section>
934    </div>
935
936    <section class="shortcuts-panel brutal-panel">
937      <div class="panel-header">
938        <h2 class="panel-title">Keyboard Shortcuts</h2>
939        <p class="panel-subtitle">Stay quick with the essentials.</p>
940      </div>
941      <div class="panel-body">
942        {#if shortcutsLoading}
943          <div class="loading">Loading shortcuts...</div>
944        {:else}
945          <div class="shortcuts-list">
946            {#each shortcuts as shortcut (shortcut.key)}
947              <div class="shortcut-item">
948                <div class="shortcut-key-badge">{shortcut.key}</div>
949                <div class="shortcut-desc">{shortcut.description}</div>
950              </div>
951            {/each}
952          </div>
953        {/if}
954      </div>
955    </section>
956  </div>
957
958  <Footer />
959</div>
960
961<style>
962/* Homepage Layout Styles */
963.homepage-shell {
964  min-height: 100vh;
965  background-color: var(--dynamic-bg);
966  color: var(--dynamic-text);
967  display: flex;
968  flex-direction: column;
969  padding: clamp(1.75rem, 3vw, 3rem);
970  font-family: "Fira Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
971}
972
973.homepage-shell * {
974  box-sizing: border-box;
975}
976
977.homepage-content {
978  flex: 1;
979  width: 100%;
980  max-width: 1180px;
981  margin: 0 auto;
982  display: flex;
983  flex-direction: column;
984  gap: clamp(1.75rem, 2.6vw, 2.75rem);
985}
986
987.top-bar {
988  display: flex;
989  justify-content: flex-end;
990}
991
992.theme-toggle-button {
993  width: 3rem;
994  height: 3rem;
995  display: inline-flex;
996  align-items: center;
997  justify-content: center;
998  border: 2px solid var(--dynamic-border);
999  border-radius: 0;
1000  background-color: var(--dynamic-surface);
1001  color: var(--dynamic-text);
1002  transition:
1003    background-color 200ms ease,
1004    color 200ms ease,
1005    border-color 200ms ease;
1006  cursor: pointer;
1007}
1008
1009.theme-toggle-button:hover,
1010.theme-toggle-button:focus-visible {
1011  border-color: var(--dynamic-accent);
1012  color: var(--dynamic-accent);
1013  outline: none;
1014}
1015
1016.theme-toggle-button svg {
1017  width: 1.5rem;
1018  height: 1.5rem;
1019}
1020
1021.sr-only {
1022  position: absolute;
1023  width: 1px;
1024  height: 1px;
1025  padding: 0;
1026  margin: -1px;
1027  overflow: hidden;
1028  clip: rect(0, 0, 0, 0);
1029  white-space: nowrap;
1030  border: 0;
1031}
1032
1033.brutal-panel {
1034  border: 2px solid var(--dynamic-border);
1035  background-color: var(--dynamic-surface);
1036  border-radius: 0;
1037  display: flex;
1038  flex-direction: column;
1039  gap: 0;
1040}
1041
1042.hero-panel {
1043  gap: clamp(1.25rem, 2vw, 1.75rem);
1044  padding: clamp(1.75rem, 2.2vw, 2.5rem);
1045}
1046
1047.hero-heading {
1048  display: flex;
1049  flex-direction: column;
1050  gap: 0.5rem;
1051  max-width: 36rem;
1052}
1053
1054.hero-title {
1055  font-size: clamp(2rem, 3vw, 2.75rem);
1056  font-weight: 700;
1057  letter-spacing: -0.01em;
1058  margin: 0;
1059}
1060
1061.hero-subtitle {
1062  font-size: 1rem;
1063  line-height: 1.6;
1064  color: var(--dynamic-muted);
1065  margin: 0;
1066}
1067
1068.quick-access-wrapper {
1069  display: flex;
1070  flex-direction: column;
1071  gap: 1rem;
1072}
1073
1074.quick-access-grid {
1075  display: flex;
1076  flex-wrap: wrap;
1077  gap: 1rem;
1078}
1079
1080.quick-access-item {
1081  flex: 1 1 min(16rem, 100%);
1082  min-width: 12rem;
1083  display: flex;
1084  align-items: center;
1085  gap: 1rem;
1086  border: 2px solid var(--dynamic-border);
1087  background-color: var(--dynamic-surface-variant);
1088  padding: 0.9rem 1.1rem;
1089  cursor: pointer;
1090  border-radius: 0;
1091  transition:
1092    border-color 200ms ease,
1093    background-color 200ms ease;
1094}
1095
1096.quick-access-item:hover,
1097.quick-access-item:focus-visible {
1098  border-color: var(--dynamic-accent);
1099  outline: none;
1100}
1101
1102.quick-access-favicon {
1103  width: 48px;
1104  height: 48px;
1105  border: 2px solid var(--dynamic-border);
1106  background-color: var(--dynamic-surface);
1107  display: flex;
1108  align-items: center;
1109  justify-content: center;
1110  flex-shrink: 0;
1111  border-radius: 0;
1112}
1113
1114.quick-access-favicon-img {
1115  width: 70%;
1116  height: 70%;
1117  object-fit: contain;
1118}
1119
1120.quick-access-fallback {
1121  width: 100%;
1122  height: 100%;
1123  display: flex;
1124  align-items: center;
1125  justify-content: center;
1126  color: var(--dynamic-muted);
1127}
1128
1129.quick-access-meta {
1130  flex: 1;
1131  min-width: 0;
1132  display: flex;
1133  flex-direction: column;
1134  gap: 0.125rem;
1135}
1136
1137.quick-access-title {
1138  font-size: 1rem;
1139  font-weight: 600;
1140  color: var(--dynamic-text);
1141  white-space: nowrap;
1142  overflow: hidden;
1143  text-overflow: ellipsis;
1144}
1145
1146.quick-access-domain {
1147  font-size: 0.85rem;
1148  color: var(--dynamic-muted);
1149  white-space: nowrap;
1150  overflow: hidden;
1151  text-overflow: ellipsis;
1152}
1153
1154.quick-access-visits {
1155  font-size: 0.82rem;
1156  color: var(--dynamic-accent);
1157  text-transform: uppercase;
1158  letter-spacing: 0.05em;
1159  font-weight: 600;
1160  flex-shrink: 0;
1161}
1162
1163.loading {
1164  padding: 1.25rem;
1165  text-align: center;
1166  color: var(--dynamic-muted);
1167  border: 2px dashed var(--dynamic-border);
1168  background-color: var(--dynamic-bg);
1169}
1170
1171.empty-state {
1172  border: 2px dashed var(--dynamic-border);
1173  padding: 1.75rem;
1174  display: flex;
1175  flex-direction: column;
1176  gap: 0.5rem;
1177  text-align: center;
1178  align-items: center;
1179  color: var(--dynamic-muted);
1180  background-color: var(--dynamic-bg);
1181}
1182
1183.empty-state h3 {
1184  margin: 0;
1185  color: var(--dynamic-text);
1186  font-size: 1.1rem;
1187}
1188
1189.empty-state p {
1190  margin: 0;
1191  color: var(--dynamic-muted);
1192}
1193
1194.empty-state.compact {
1195  padding: 1.25rem;
1196}
1197
1198/* Main panels with height matching */
1199.main-panels {
1200  display: flex;
1201  flex-wrap: wrap;
1202  gap: clamp(1.5rem, 2vw, 2rem);
1203  align-items: flex-start;
1204}
1205
1206.history-panel {
1207  flex: 1 1 55%;
1208  min-width: min(32rem, 100%);
1209  display: flex;
1210  flex-direction: column;
1211  min-height: 0;
1212}
1213
1214.insights-panel {
1215  flex: 1 1 35%;
1216  min-width: min(24rem, 100%);
1217  display: flex;
1218  flex-direction: column;
1219  min-height: 0;
1220}
1221
1222.panel-header {
1223  display: flex;
1224  flex-direction: column;
1225  gap: 0.4rem;
1226  padding: clamp(1.25rem, 2vw, 1.75rem) clamp(1.5rem, 2.5vw, 2rem);
1227  border-bottom: 2px solid var(--dynamic-border);
1228  background-color: var(--dynamic-surface);
1229}
1230
1231.panel-title {
1232  margin: 0;
1233  font-size: 1.3rem;
1234  font-weight: 600;
1235}
1236
1237.panel-subtitle {
1238  margin: 0;
1239  font-size: 0.95rem;
1240  color: var(--dynamic-muted);
1241}
1242
1243.panel-body {
1244  padding: clamp(1.25rem, 2vw, 1.75rem) clamp(1.5rem, 2.5vw, 2rem);
1245  display: flex;
1246  flex-direction: column;
1247  gap: 1.1rem;
1248  min-height: 0;
1249  overflow: hidden;
1250}
1251
1252.history-body {
1253  gap: 1rem;
1254  display: flex;
1255  flex-direction: column;
1256  min-height: 0;
1257  overflow: hidden;
1258}
1259
1260.history-list {
1261  display: flex;
1262  flex-direction: column;
1263  gap: 0.85rem;
1264  flex: 1;
1265  min-height: 0;
1266  overflow-y: auto;
1267  padding-right: 0.5rem;
1268}
1269
1270.history-item {
1271  display: flex;
1272  align-items: flex-start;
1273  gap: 1rem;
1274  border: 2px solid var(--dynamic-border);
1275  background-color: var(--dynamic-surface-variant);
1276  padding: 1rem 1.1rem;
1277  cursor: pointer;
1278  min-height: 80px;
1279  border-radius: 0;
1280  transition:
1281    border-color 200ms ease,
1282    background-color 200ms ease;
1283}
1284
1285.history-item:hover,
1286.history-item:focus-visible {
1287  border-color: var(--dynamic-accent);
1288  background-color: var(--dynamic-bg);
1289  outline: none;
1290}
1291
1292.history-item.deleting {
1293  opacity: 0.4;
1294  pointer-events: none;
1295  transform: translateX(6px);
1296}
1297
1298.history-item-leading {
1299  width: 44px;
1300  height: 44px;
1301  border: 2px solid var(--dynamic-border);
1302  background-color: var(--dynamic-surface);
1303  display: flex;
1304  align-items: center;
1305  justify-content: center;
1306  flex-shrink: 0;
1307  border-radius: 0;
1308}
1309
1310.history-favicon-img {
1311  width: 60%;
1312  height: 60%;
1313  object-fit: contain;
1314}
1315
1316.history-favicon-fallback {
1317  display: flex;
1318  align-items: center;
1319  justify-content: center;
1320  width: 100%;
1321  height: 100%;
1322  color: var(--dynamic-muted);
1323}
1324
1325.history-item-content {
1326  flex: 1;
1327  min-width: 0;
1328  display: flex;
1329  flex-direction: column;
1330  gap: 0.125rem;
1331  justify-content: center;
1332}
1333
1334.history-item-top {
1335  display: flex;
1336  align-items: flex-start;
1337  justify-content: space-between;
1338  gap: 1rem;
1339}
1340
1341.history-title {
1342  font-weight: 600;
1343  color: var(--dynamic-text);
1344  white-space: nowrap;
1345  overflow: hidden;
1346  text-overflow: ellipsis;
1347}
1348
1349.history-time {
1350  font-size: 0.85rem;
1351  color: var(--dynamic-muted);
1352  flex-shrink: 0;
1353}
1354
1355.history-item-bottom {
1356  display: flex;
1357  align-items: baseline;
1358  gap: 0.5rem;
1359  overflow: hidden;
1360  position: relative;
1361}
1362
1363.history-domain {
1364  font-size: 0.9rem;
1365  font-weight: 500;
1366  color: var(--dynamic-text);
1367  white-space: nowrap;
1368  flex-shrink: 0;
1369}
1370
1371.history-url {
1372  font-size: 0.8rem;
1373  color: var(--dynamic-muted);
1374  white-space: nowrap;
1375  font-family:
1376    "JetBrains Mono",
1377    "SFMono-Regular",
1378    Menlo,
1379    monospace;
1380  overflow: hidden;
1381  flex: 1;
1382  position: relative;
1383  mask-image: linear-gradient(to right, black 70%, transparent 100%);
1384  -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
1385}
1386
1387.history-delete {
1388  display: flex;
1389  align-items: center;
1390  justify-content: center;
1391  width: 38px;
1392  border-left: 2px solid var(--dynamic-border);
1393  background-color: var(--dynamic-surface);
1394  color: var(--dynamic-muted);
1395  font-size: 1.1rem;
1396  transition: background-color 200ms ease, color 200ms ease;
1397  flex-shrink: 0;
1398}
1399
1400.history-delete:hover,
1401.history-delete:focus-visible {
1402  color: var(--dynamic-accent);
1403  background-color: var(--dynamic-bg);
1404  outline: none;
1405}
1406
1407.scroll-sentinel {
1408  width: 100%;
1409  height: 1px;
1410}
1411
1412.loading-more {
1413  padding: 0.75rem;
1414  text-align: center;
1415  color: var(--dynamic-muted);
1416  font-size: 0.85rem;
1417}
1418
1419.insights-body {
1420  gap: 1.5rem;
1421}
1422
1423.insight-block {
1424  display: flex;
1425  flex-direction: column;
1426  gap: 0.9rem;
1427}
1428
1429.block-title {
1430  font-size: 0.85rem;
1431  text-transform: uppercase;
1432  letter-spacing: 0.08em;
1433  color: var(--dynamic-muted);
1434  margin: 0;
1435}
1436
1437.insights-list {
1438  display: flex;
1439  flex-direction: column;
1440  gap: 0.75rem;
1441}
1442
1443.insight-item {
1444  display: flex;
1445  align-items: center;
1446  gap: 0.75rem;
1447  border: 2px solid var(--dynamic-border);
1448  background-color: var(--dynamic-surface-variant);
1449  padding: 0.85rem 1rem;
1450  cursor: pointer;
1451  border-radius: 0;
1452  transition:
1453    border-color 200ms ease,
1454    background-color 200ms ease;
1455}
1456
1457.insight-item:hover,
1458.insight-item:focus-visible {
1459  border-color: var(--dynamic-accent);
1460  outline: none;
1461}
1462
1463.insight-rank {
1464  width: 2rem;
1465  height: 2rem;
1466  border: 2px solid var(--dynamic-border);
1467  background-color: var(--dynamic-surface);
1468  display: flex;
1469  align-items: center;
1470  justify-content: center;
1471  font-weight: 600;
1472  font-size: 0.9rem;
1473  color: var(--dynamic-accent);
1474  flex-shrink: 0;
1475  border-radius: 0;
1476}
1477
1478.insight-body {
1479  flex: 1;
1480  min-width: 0;
1481  display: flex;
1482  flex-direction: column;
1483  gap: 0.125rem;
1484}
1485
1486.insight-title {
1487  font-weight: 600;
1488  color: var(--dynamic-text);
1489  white-space: nowrap;
1490  overflow: hidden;
1491  text-overflow: ellipsis;
1492}
1493
1494.insight-domain {
1495  font-size: 0.85rem;
1496  color: var(--dynamic-muted);
1497  white-space: nowrap;
1498  overflow: hidden;
1499  text-overflow: ellipsis;
1500}
1501
1502.insight-visits {
1503  font-size: 0.78rem;
1504  color: var(--dynamic-accent);
1505  text-transform: uppercase;
1506  letter-spacing: 0.05em;
1507  font-weight: 600;
1508  flex-shrink: 0;
1509}
1510
1511.stats-grid {
1512  display: flex;
1513  flex-wrap: wrap;
1514  gap: 0.85rem;
1515}
1516
1517.stat-item {
1518  flex: 1 1 160px;
1519  border: 2px solid var(--dynamic-border);
1520  background-color: var(--dynamic-surface-variant);
1521  padding: 1rem;
1522  display: flex;
1523  flex-direction: column;
1524  gap: 0.35rem;
1525  border-radius: 0;
1526}
1527
1528.stat-value {
1529  font-size: 1.4rem;
1530  font-weight: 600;
1531  color: var(--dynamic-text);
1532}
1533
1534.stat-label {
1535  font-size: 0.8rem;
1536  color: var(--dynamic-muted);
1537  text-transform: uppercase;
1538  letter-spacing: 0.08em;
1539}
1540
1541.shortcuts-panel {
1542  gap: 0;
1543}
1544
1545.shortcuts-list {
1546  display: flex;
1547  flex-wrap: wrap;
1548  gap: 0.85rem;
1549}
1550
1551.shortcut-item {
1552  display: flex;
1553  align-items: center;
1554  gap: 0.75rem;
1555  border: 2px solid var(--dynamic-border);
1556  background-color: var(--dynamic-bg);
1557  padding: 0.75rem 1rem;
1558  border-radius: 0;
1559}
1560
1561.shortcut-key-badge {
1562  font-family:
1563    "JetBrains Mono",
1564    "SFMono-Regular",
1565    Menlo,
1566    monospace;
1567  font-size: 0.85rem;
1568  text-transform: uppercase;
1569  letter-spacing: 0.08em;
1570  padding: 0.35rem 0.65rem;
1571  border: 2px solid var(--dynamic-border);
1572  background-color: var(--dynamic-surface-variant);
1573  color: var(--dynamic-muted);
1574  font-weight: 500;
1575  border-radius: 0;
1576}
1577
1578.shortcut-desc {
1579  font-size: 0.95rem;
1580  color: var(--dynamic-text);
1581}
1582
1583.shortcuts-panel .loading {
1584  border-style: solid;
1585}
1586
1587/* Responsive Design */
1588@media (max-width: 1200px) {
1589  .history-panel,
1590  .insights-panel {
1591    min-width: min(28rem, 100%);
1592  }
1593}
1594
1595@media (max-width: 960px) {
1596  .homepage-shell {
1597    padding: clamp(1.25rem, 5vw, 2rem);
1598  }
1599
1600  .main-panels {
1601    flex-direction: column;
1602  }
1603
1604  .history-panel,
1605  .insights-panel {
1606    min-width: 100%;
1607  }
1608
1609  .quick-access-item {
1610    flex: 1 1 calc(50% - 1rem);
1611  }
1612}
1613
1614@media (max-width: 640px) {
1615  .theme-toggle-button {
1616    width: 2.75rem;
1617    height: 2.75rem;
1618  }
1619
1620  .panel-body,
1621  .panel-header,
1622  .hero-panel {
1623    padding-left: 1.25rem;
1624    padding-right: 1.25rem;
1625  }
1626
1627  .quick-access-item {
1628    flex: 1 1 100%;
1629  }
1630
1631  .history-item {
1632    flex-direction: column;
1633    align-items: flex-start;
1634  }
1635
1636  .history-delete {
1637    width: 100%;
1638    border-left: 0;
1639    border-top: 2px solid var(--dynamic-border);
1640    align-self: stretch;
1641  }
1642
1643  .history-item-top {
1644    flex-direction: column;
1645    align-items: flex-start;
1646    gap: 0.35rem;
1647  }
1648
1649  .history-time {
1650    order: 3;
1651  }
1652}
1653</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