v0.3.0 d226234

dom.ts RawHistoryFolder

1type HistoryEntry = { id: number; url: string; title: string };
2type SearchShortcut = { description: string; url: string };
3
4export class DOMRenderer {
5  private historyListElement: HTMLElement | null = null;
6  private shortcutsElement: HTMLElement | null = null;
7
8  constructor() {
9    this.historyListElement = document.getElementById('historyList');
10    this.shortcutsElement = document.getElementById('shortcuts');
11  }
12
13  displayHistory(historyEntries: HistoryEntry[]): void {
14    if (!this.historyListElement) return;
15
16    if (!historyEntries || historyEntries.length === 0) {
17      this.historyListElement.innerHTML = `
18        <div class="empty-state">
19          <h3>No history yet</h3>
20          <p>Use CLI commands to browse websites</p>
21        </div>
22      `;
23      return;
24    }
25
26    this.historyListElement.innerHTML = '';
27    historyEntries.forEach(item => {
28      const historyItem = this.createHistoryItem(item);
29      this.historyListElement!.appendChild(historyItem);
30    });
31  }
32
33  displayShortcuts(shortcutsData: Record<string, SearchShortcut>): void {
34    if (!this.shortcutsElement) return;
35
36    if (!shortcutsData || Object.keys(shortcutsData).length === 0) {
37      this.shortcutsElement.innerHTML = `
38        <div class="empty-state">
39          <h3>No shortcuts configured</h3>
40          <p>Add shortcuts to your config file</p>
41        </div>
42      `;
43      return;
44    }
45
46    this.shortcutsElement.innerHTML = '';
47    Object.entries(shortcutsData).forEach(([key, shortcut]) => {
48      const shortcutEl = this.createShortcutElement(key, shortcut);
49      this.shortcutsElement!.appendChild(shortcutEl);
50    });
51  }
52
53  showLoading(container: 'history' | 'shortcuts'): void {
54    const element = container === 'history' ? this.historyListElement : this.shortcutsElement;
55    if (element) {
56      element.innerHTML = '<div class="loading">Loading...</div>';
57    }
58  }
59
60  showError(container: 'history' | 'shortcuts', message: string): void {
61    const element = container === 'history' ? this.historyListElement : this.shortcutsElement;
62    if (element) {
63      element.innerHTML = `
64        <div class="empty-state">
65          <h3>Error</h3>
66          <p>${message}</p>
67        </div>
68      `;
69    }
70  }
71
72  private createHistoryItem(item: HistoryEntry): HTMLElement {
73    const historyItem = document.createElement('div');
74    historyItem.className = 'history-item';
75    const parsed = this.safeParseURL(item.url);
76    const title = item.title && item.title.trim() !== '' ? item.title : (parsed.host || 'Untitled');
77
78    const line = document.createElement('div');
79    line.className = 'history-line';
80
81    // Favicon chip (wrapper ensures perfect circle sizing)
82    const iconWrap = document.createElement('div');
83    iconWrap.className = 'history-favicon-chip';
84    const icon = document.createElement('img');
85    icon.className = 'history-favicon-img';
86    icon.width = 16; icon.height = 16;
87    icon.loading = 'lazy';
88    icon.referrerPolicy = 'no-referrer';
89    icon.src = this.buildFaviconURL(item.url);
90    icon.addEventListener('error', () => { iconWrap.style.display = 'none'; });
91    iconWrap.appendChild(icon);
92
93    const titleEl = document.createElement('span');
94    titleEl.className = 'history-title';
95    titleEl.textContent = title;
96
97    const sep1 = document.createElement('span');
98    sep1.className = 'history-sep';
99    sep1.textContent = ' - ';
100
101    const domainEl = document.createElement('span');
102    domainEl.className = 'history-domain';
103    domainEl.textContent = parsed.host || '';
104
105    const sep2 = document.createElement('span');
106    sep2.className = 'history-sep';
107    sep2.textContent = ' - ';
108
109    const urlEl = document.createElement('span');
110    urlEl.className = 'history-url';
111    urlEl.textContent = item.url;
112
113    line.appendChild(iconWrap);
114    line.appendChild(titleEl);
115    line.appendChild(sep1);
116    line.appendChild(domainEl);
117    line.appendChild(sep2);
118    line.appendChild(urlEl);
119
120    historyItem.appendChild(line);
121    
122    historyItem.addEventListener('click', () => {
123      this.navigateToUrl(item.url);
124    });
125    
126    return historyItem;
127  }
128
129  private createShortcutElement(key: string, shortcut: SearchShortcut): HTMLElement {
130    const shortcutEl = document.createElement('div');
131    shortcutEl.className = 'shortcut';
132    shortcutEl.innerHTML = `
133      <div class="shortcut-key">${this.escapeHtml(key)}:</div>
134      <div class="shortcut-desc">${this.escapeHtml(shortcut.description)}</div>
135    `;
136    
137    shortcutEl.addEventListener('click', () => {
138      // Navigate to the base URL of the shortcut (remove %s template parameter)
139      const baseUrl = this.extractBaseUrl(shortcut.url);
140      this.navigateToUrl(baseUrl);
141    });
142    
143    return shortcutEl;
144  }
145
146  private async navigateToUrl(url: string): Promise<void> {
147    console.log('Navigating to:', url);
148    window.location.href = url;
149  }
150
151  private extractBaseUrl(templateUrl: string): string {
152    try {
153      // Support both %s and {query} placeholders
154      const candidate = templateUrl.replace(/%s|\{query\}/g, "");
155      const url = new URL(candidate);
156      // Remove empty query params left after placeholder removal
157      const params = new URLSearchParams(url.search);
158      for (const key of Array.from(params.keys())) {
159        const v = params.get(key);
160        if (v === null || v === "") {
161          params.delete(key);
162        }
163      }
164      url.search = params.toString();
165      return url.toString();
166    } catch (error) {
167      // Fallbacks: try to return origin or stripped placeholder
168      console.warn('Failed to parse shortcut URL:', templateUrl);
169      try {
170        const pre = templateUrl.split('%s')[0] || templateUrl.split('{query}')[0] || templateUrl;
171        const u = new URL(pre);
172        return `${u.protocol}//${u.host}`;
173      } catch {
174        return templateUrl.replace(/%s|\{query\}/g, "");
175      }
176    }
177  }
178
179  private async copyToClipboard(text: string): Promise<void> {
180    try {
181      await navigator.clipboard.writeText(text);
182      console.log('Copied to clipboard:', text);
183    } catch (err) {
184      console.error('Failed to copy:', err);
185    }
186  }
187
188  private escapeHtml(text: string): string {
189    const div = document.createElement('div');
190    div.textContent = text;
191    return div.innerHTML;
192  }
193
194  private safeParseURL(raw: string): { host: string } {
195    try {
196      const u = new URL(raw);
197      return { host: u.host };
198    } catch {
199      return { host: '' };
200    }
201  }
202
203  private buildFaviconURL(raw: string): string {
204    try {
205      const u = new URL(raw);
206      const scheme = u.protocol && u.protocol !== ':' ? u.protocol : 'https:';
207      return `${scheme}//${u.host}/favicon.ico`;
208    } catch {
209      return '';
210    }
211  }
212}

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