haltija 1.1.21 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Settings modal and new tab dialog.
3
+ */
4
+
5
+ import { settings, saveSettings as persistSettings, el } from './state.js'
6
+ import { checkHaltija } from './status.js'
7
+
8
+ let pendingNewTabResolve = null
9
+
10
+ export function showNewTabDialog(url) {
11
+ return new Promise((resolve) => {
12
+ pendingNewTabResolve = resolve
13
+ el.newTabUrlEl.textContent = url
14
+ el.newTabDialog.classList.remove('hidden')
15
+ })
16
+ }
17
+
18
+ export function hideNewTabDialog(allowed) {
19
+ el.newTabDialog.classList.add('hidden')
20
+ if (pendingNewTabResolve) {
21
+ pendingNewTabResolve(allowed)
22
+ pendingNewTabResolve = null
23
+ }
24
+ }
25
+
26
+ export function showSettings() {
27
+ document.querySelector(`input[name="server-mode"][value="${settings.serverMode}"]`).checked = true
28
+ document.getElementById('server-url').value = settings.serverUrl
29
+ document.getElementById('confirm-new-tabs').checked = settings.confirmNewTabs
30
+ el.settingsModal.classList.remove('hidden')
31
+ }
32
+
33
+ export function hideSettings() {
34
+ el.settingsModal.classList.add('hidden')
35
+ }
36
+
37
+ export function applySettings() {
38
+ settings.serverMode = document.querySelector('input[name="server-mode"]:checked').value
39
+ settings.serverUrl = document.getElementById('server-url').value || 'http://localhost:8700'
40
+ settings.confirmNewTabs = document.getElementById('confirm-new-tabs').checked
41
+ persistSettings()
42
+ hideSettings()
43
+ checkHaltija()
44
+ }
45
+
46
+ export function initSettingsListeners() {
47
+ el.settingsBtn.addEventListener('click', showSettings)
48
+ el.closeSettingsBtn.addEventListener('click', hideSettings)
49
+ el.saveSettingsBtn.addEventListener('click', applySettings)
50
+ el.settingsModal.addEventListener('click', (e) => {
51
+ if (e.target === el.settingsModal) hideSettings()
52
+ })
53
+ el.allowNewTabBtn.addEventListener('click', () => hideNewTabDialog(true))
54
+ el.denyNewTabBtn.addEventListener('click', () => hideNewTabDialog(false))
55
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shared renderer state — tab list, settings, DOM element references.
3
+ * All modules import from here instead of accessing globals.
4
+ */
5
+
6
+ // Settings
7
+ const DEFAULT_SETTINGS = {
8
+ serverMode: 'builtin',
9
+ serverUrl: 'http://localhost:8700',
10
+ confirmNewTabs: false,
11
+ }
12
+
13
+ export let settings = { ...DEFAULT_SETTINGS }
14
+
15
+ export function loadSettings() {
16
+ try {
17
+ const saved = localStorage.getItem('haltija-settings')
18
+ if (saved) {
19
+ settings = { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }
20
+ }
21
+ } catch (e) {
22
+ console.error('[Haltija Desktop] Failed to load settings:', e)
23
+ }
24
+ }
25
+
26
+ export function saveSettings() {
27
+ try {
28
+ localStorage.setItem('haltija-settings', JSON.stringify(settings))
29
+ } catch (e) {
30
+ console.error('[Haltija Desktop] Failed to save settings:', e)
31
+ }
32
+ }
33
+
34
+ export function getServerUrl() {
35
+ return settings.serverUrl || DEFAULT_SETTINGS.serverUrl
36
+ }
37
+
38
+ // Tab state
39
+ export const tabs = []
40
+ export let activeTabId = null
41
+ export let tabIdCounter = 0
42
+ export let lastCwd = localStorage.getItem('haltija-lastCwd') || null
43
+
44
+ export function setActiveTabId(id) { activeTabId = id }
45
+ export function nextTabId() { return `tab-${++tabIdCounter}` }
46
+ export function setLastCwd(cwd) {
47
+ lastCwd = cwd
48
+ localStorage.setItem('haltija-lastCwd', cwd)
49
+ }
50
+
51
+ // DOM element references (initialized in renderer.js after DOM is ready)
52
+ export const el = {
53
+ tabBar: null,
54
+ newTabButton: null,
55
+ toolbar: null,
56
+ urlInput: null,
57
+ goButton: null,
58
+ backButton: null,
59
+ forwardButton: null,
60
+ refreshButton: null,
61
+ webviewContainer: null,
62
+ statusDot: null,
63
+ settingsBtn: null,
64
+ settingsModal: null,
65
+ closeSettingsBtn: null,
66
+ saveSettingsBtn: null,
67
+ newTabDialog: null,
68
+ newTabUrlEl: null,
69
+ allowNewTabBtn: null,
70
+ denyNewTabBtn: null,
71
+ agentStatusBar: null,
72
+ agentStatusItems: null,
73
+ agentSelect: null,
74
+ }
75
+
76
+ export function initElements() {
77
+ el.tabBar = document.getElementById('tabs')
78
+ el.newTabButton = document.getElementById('new-tab')
79
+ el.toolbar = document.getElementById('toolbar')
80
+ el.urlInput = document.getElementById('url-input')
81
+ el.goButton = document.getElementById('go')
82
+ el.backButton = document.getElementById('back')
83
+ el.forwardButton = document.getElementById('forward')
84
+ el.refreshButton = document.getElementById('refresh')
85
+ el.webviewContainer = document.getElementById('webview-container')
86
+ el.statusDot = document.getElementById('haltija-status')
87
+ el.settingsBtn = document.getElementById('settings-btn')
88
+ el.settingsModal = document.getElementById('settings-modal')
89
+ el.closeSettingsBtn = document.getElementById('close-settings')
90
+ el.saveSettingsBtn = document.getElementById('save-settings')
91
+ el.newTabDialog = document.getElementById('new-tab-dialog')
92
+ el.newTabUrlEl = document.getElementById('new-tab-url')
93
+ el.allowNewTabBtn = document.getElementById('allow-new-tab')
94
+ el.denyNewTabBtn = document.getElementById('deny-new-tab')
95
+ el.agentStatusBar = document.getElementById('agent-status-bar')
96
+ el.agentStatusItems = document.getElementById('agent-status-items')
97
+ el.agentSelect = document.getElementById('agent-select')
98
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Server status checking and nav button updates.
3
+ */
4
+
5
+ import { el, getServerUrl } from './state.js'
6
+
7
+ export async function checkHaltija() {
8
+ try {
9
+ const response = await fetch(`${getServerUrl()}/status`)
10
+ if (response.ok) {
11
+ el.statusDot.className = 'status-dot connected'
12
+ el.statusDot.title = 'Haltija: Connected'
13
+ } else {
14
+ throw new Error('Not OK')
15
+ }
16
+ } catch {
17
+ el.statusDot.className = 'status-dot disconnected'
18
+ el.statusDot.title = 'Haltija: Disconnected - Start server with: bunx haltija'
19
+ }
20
+ }
21
+
22
+ export function updateNavButtons() {
23
+ // Import dynamically to avoid circular dependency
24
+ const { getActiveWebview } = window._tabs
25
+ const webview = getActiveWebview()
26
+ if (webview) {
27
+ try {
28
+ el.backButton.disabled = !webview.canGoBack()
29
+ el.forwardButton.disabled = !webview.canGoForward()
30
+ } catch (e) {
31
+ el.backButton.disabled = true
32
+ el.forwardButton.disabled = true
33
+ }
34
+ } else {
35
+ el.backButton.disabled = true
36
+ el.forwardButton.disabled = true
37
+ }
38
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Tab management — create, activate, close, navigate browser tabs.
3
+ */
4
+
5
+ import { tabs, activeTabId, setActiveTabId, nextTabId, el, getServerUrl, lastCwd, setLastCwd } from './state.js'
6
+ import { showNotification } from './ui-utils.js'
7
+ import { setupWebviewEvents } from './webview-events.js'
8
+ import { checkHaltija, updateNavButtons } from './status.js'
9
+
10
+ export function getDefaultUrl() {
11
+ return `${getServerUrl()}/test`
12
+ }
13
+
14
+ export function createTab(url, activate = true) {
15
+ const tabId = nextTabId()
16
+ const tabUrl = url || getDefaultUrl()
17
+
18
+ const tabEl = document.createElement('div')
19
+ tabEl.className = 'tab'
20
+ tabEl.dataset.tabId = tabId
21
+ tabEl.innerHTML = `
22
+ <span class="tab-title">New Tab</span>
23
+ <button class="tab-close" title="Close tab">×</button>
24
+ `
25
+
26
+ tabEl.addEventListener('click', (e) => {
27
+ if (!e.target.classList.contains('tab-close')) {
28
+ activateTab(tabId)
29
+ }
30
+ })
31
+
32
+ tabEl.querySelector('.tab-close').addEventListener('click', (e) => {
33
+ e.stopPropagation()
34
+ closeTab(tabId)
35
+ })
36
+
37
+ el.tabBar.appendChild(tabEl)
38
+
39
+ const webview = document.createElement('webview')
40
+ webview.id = tabId
41
+ webview.src = 'about:blank'
42
+ if (window.haltija?.webviewPreloadPath) {
43
+ webview.setAttribute('preload', 'file://' + window.haltija.webviewPreloadPath)
44
+ }
45
+ webview.setAttribute(
46
+ 'webpreferences',
47
+ 'contextIsolation=yes, nodeIntegration=no, webSecurity=no, allowRunningInsecureContent=yes',
48
+ )
49
+ webview.setAttribute('allowpopups', '')
50
+
51
+ el.webviewContainer.appendChild(webview)
52
+
53
+ const tab = {
54
+ id: tabId,
55
+ url: tabUrl,
56
+ title: 'New Tab',
57
+ element: tabEl,
58
+ webview: webview,
59
+ }
60
+ tabs.push(tab)
61
+
62
+ setupWebviewEvents(tab)
63
+
64
+ if (activate) {
65
+ activateTab(tabId)
66
+ }
67
+
68
+ webview.addEventListener('did-attach', () => {
69
+ navigate(tabUrl, tabId)
70
+ }, { once: true })
71
+
72
+ const fallbackTabId = tabId
73
+ setTimeout(() => {
74
+ const t = tabs.find(tt => tt.id === fallbackTabId)
75
+ if (t && !t.isTerminal && (webview.getURL() === 'about:blank' || !webview.getURL())) {
76
+ navigate(tabUrl, fallbackTabId)
77
+ }
78
+ }, 500)
79
+
80
+ return tab
81
+ }
82
+
83
+ export async function createTerminalTab(mode = 'human') {
84
+ const tabId = nextTabId()
85
+ const isAgent = mode === 'agent'
86
+ const prefix = isAgent ? '<span class="agent-status">*</span>' : '>'
87
+
88
+ if (isAgent) {
89
+ await checkHjInstalled()
90
+ }
91
+
92
+ const activeTab = getActiveTab()
93
+ const initialCwd = (activeTab?.isTerminal && activeTab?.cwd) || lastCwd || ''
94
+ const label = isAgent ? 'agent' : 'shell'
95
+
96
+ const tabEl = document.createElement('div')
97
+ tabEl.className = `tab ${isAgent ? 'agent ready' : 'terminal'}`
98
+ tabEl.dataset.tabId = tabId
99
+ tabEl.innerHTML = `
100
+ <span class="tab-title">${prefix} ${label}</span>
101
+ <button class="tab-close" title="Close tab">×</button>
102
+ `
103
+
104
+ tabEl.addEventListener('click', (e) => {
105
+ if (!e.target.classList.contains('tab-close')) {
106
+ activateTab(tabId)
107
+ }
108
+ })
109
+
110
+ tabEl.querySelector('.tab-close').addEventListener('click', (e) => {
111
+ e.stopPropagation()
112
+ closeTab(tabId)
113
+ })
114
+
115
+ el.tabBar.appendChild(tabEl)
116
+
117
+ const iframe = document.createElement('iframe')
118
+ iframe.id = tabId
119
+ const cwdParam = initialCwd ? `&cwd=${encodeURIComponent(initialCwd)}` : ''
120
+ iframe.src = `terminal.html?port=${window.haltija?.port || 8700}&mode=${mode}${cwdParam}`
121
+ iframe.className = 'terminal-frame'
122
+
123
+ el.webviewContainer.appendChild(iframe)
124
+
125
+ const tab = {
126
+ id: tabId,
127
+ url: 'terminal',
128
+ title: label,
129
+ element: tabEl,
130
+ webview: iframe,
131
+ isTerminal: true,
132
+ terminalMode: mode,
133
+ }
134
+ tabs.push(tab)
135
+
136
+ activateTab(tabId)
137
+ return tab
138
+ }
139
+
140
+ export function renameTerminalTab(tab, name) {
141
+ if (!name) return
142
+ tab.shellName = name
143
+ tab.title = name
144
+ const prefix = tab.terminalMode === 'agent' ? '*' : '>'
145
+ tab.element.querySelector('.tab-title').textContent = `${prefix} ${name}`
146
+ document.title = name
147
+ const iframe = tab.webview
148
+ if (iframe && iframe.contentWindow) {
149
+ iframe.contentWindow.postMessage({ type: 'rename', name }, '*')
150
+ }
151
+ }
152
+
153
+ export function activateTab(tabId) {
154
+ const tab = tabs.find((t) => t.id === tabId)
155
+ if (!tab) return
156
+
157
+ tabs.forEach((t) => {
158
+ t.element.classList.remove('active')
159
+ t.webview.classList.remove('active')
160
+ })
161
+
162
+ tab.element.classList.add('active')
163
+ tab.webview.classList.add('active')
164
+ setActiveTabId(tabId)
165
+
166
+ el.toolbar.classList.remove('terminal', 'agent')
167
+ if (tab.isTerminal || tab.url === 'terminal') {
168
+ el.urlInput.value = tab.cwd || '~'
169
+ el.urlInput.placeholder = 'working directory'
170
+ el.goButton.textContent = 'Pick folder\u2026'
171
+ el.goButton.title = 'Pick folder\u2026'
172
+ el.toolbar.classList.add(tab.terminalMode === 'agent' ? 'agent' : 'terminal')
173
+
174
+ if (window._currentStatusLine) {
175
+ el.agentStatusBar.classList.remove('hidden')
176
+ }
177
+
178
+ if (tab.terminalMode === 'agent' && tab.shellId) {
179
+ fetch(`${getServerUrl()}/terminal/agent-focus`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({ shellId: tab.shellId }),
183
+ }).catch(() => {})
184
+ }
185
+ } else {
186
+ el.urlInput.value = tab.url || ''
187
+ el.urlInput.placeholder = 'Enter URL...'
188
+ el.goButton.textContent = 'Go'
189
+ el.goButton.title = 'Go'
190
+
191
+ el.agentStatusBar.classList.add('hidden')
192
+ }
193
+ document.title = tab.title || 'Haltija'
194
+
195
+ updateNavButtons()
196
+ }
197
+
198
+ export function findTabByWindowId(windowId) {
199
+ if (!windowId) return null
200
+ const wcId = parseInt(windowId.split('-').pop(), 10)
201
+ if (isNaN(wcId)) return null
202
+ return tabs.find(t => {
203
+ try {
204
+ return t.webview && t.webview.getWebContentsId && t.webview.getWebContentsId() === wcId
205
+ } catch { return false }
206
+ }) || null
207
+ }
208
+
209
+ export function closeTab(tabId) {
210
+ const tabIndex = tabs.findIndex((t) => t.id === tabId)
211
+ if (tabIndex === -1) return
212
+
213
+ const tab = tabs[tabIndex]
214
+
215
+ if (tab.webview && tab.webview.tagName === 'WEBVIEW') {
216
+ try {
217
+ tab.webview.stop()
218
+ tab.webview.src = 'about:blank'
219
+ } catch (e) {}
220
+ }
221
+
222
+ tab.element.remove()
223
+ tab.webview.remove()
224
+
225
+ tabs.splice(tabIndex, 1)
226
+
227
+ if (activeTabId === tabId) {
228
+ if (tabs.length > 0) {
229
+ const newIndex = Math.max(0, tabIndex - 1)
230
+ activateTab(tabs[newIndex].id)
231
+ } else {
232
+ window.haltija.closeWindow()
233
+ }
234
+ }
235
+ }
236
+
237
+ export function getActiveTab() {
238
+ return tabs.find((t) => t.id === activeTabId)
239
+ }
240
+
241
+ export function getActiveWebview() {
242
+ const tab = getActiveTab()
243
+ return tab ? tab.webview : null
244
+ }
245
+
246
+ export function navigate(url, tabId = activeTabId) {
247
+ const tab = tabs.find((t) => t.id === tabId)
248
+ if (!tab) return
249
+
250
+ let addedHttps = false
251
+
252
+ if (url && !url.match(/^(https?|blob|data|file|about|javascript):\/?\/?/i)) {
253
+ if (url.includes('.') || url === 'localhost' || url.startsWith('localhost:')) {
254
+ addedHttps = true
255
+ url = 'https://' + url
256
+ } else {
257
+ url = 'https://www.google.com/search?q=' + encodeURIComponent(url)
258
+ }
259
+ }
260
+
261
+ tab.url = url || getDefaultUrl()
262
+
263
+ if (addedHttps) {
264
+ const httpUrl = tab.url.replace(/^https:/, 'http:')
265
+
266
+ const failHandler = (e) => {
267
+ if (e.errorCode < 0) {
268
+ console.log(`[Haltija] HTTPS failed (${e.errorCode}), trying HTTP...`)
269
+ tab.webview.removeEventListener('did-fail-load', failHandler)
270
+ tab.url = httpUrl
271
+ tab.webview.src = httpUrl
272
+ if (tabId === activeTabId) {
273
+ el.urlInput.value = httpUrl
274
+ }
275
+ }
276
+ }
277
+ tab.webview.addEventListener('did-fail-load', failHandler, { once: true })
278
+
279
+ tab.webview.addEventListener('did-finish-load', () => {
280
+ tab.webview.removeEventListener('did-fail-load', failHandler)
281
+ }, { once: true })
282
+ }
283
+
284
+ tab.webview.src = tab.url
285
+
286
+ if (tabId === activeTabId && !tab.isTerminal) {
287
+ el.urlInput.value = tab.url
288
+ }
289
+ }
290
+
291
+ export async function changeTerminalDirectory(tab, path) {
292
+ if (!path) return
293
+ try {
294
+ const resp = await fetch(`${getServerUrl()}/terminal/command`, {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({ command: `cd ${path}`, shellId: tab.shellId }),
298
+ })
299
+ const result = await resp.text()
300
+ if (result.startsWith('cd:')) {
301
+ showNotification(result, 3000)
302
+ }
303
+ } catch (err) {
304
+ showNotification(`Error: ${err.message}`, 3000)
305
+ }
306
+ }
307
+
308
+ export async function openFolderPicker(tab) {
309
+ if (window.haltija?.showOpenDialog) {
310
+ const result = await window.haltija.showOpenDialog({
311
+ properties: ['openDirectory', 'createDirectory'],
312
+ defaultPath: tab.cwd || undefined,
313
+ })
314
+ if (result && !result.canceled && result.filePaths?.[0]) {
315
+ const picked = result.filePaths[0]
316
+ setLastCwd(picked)
317
+ changeTerminalDirectory(tab, picked)
318
+ }
319
+ } else {
320
+ showNotification('Folder picker not available', 2000)
321
+ }
322
+ }
323
+
324
+ // hj CLI install check
325
+ let hjCheckDone = false
326
+
327
+ async function checkHjInstalled() {
328
+ if (hjCheckDone) return
329
+ hjCheckDone = true
330
+
331
+ try {
332
+ const response = await fetch(`${getServerUrl()}/terminal/hj-status`)
333
+ if (!response.ok) return
334
+
335
+ const { installed, installCommand, message } = await response.json()
336
+ if (installed) return
337
+
338
+ showHjInstallPrompt(installCommand, message)
339
+ } catch (err) {
340
+ console.log('[Haltija Desktop] Could not check hj status:', err.message)
341
+ }
342
+ }
343
+
344
+ function showHjInstallPrompt(installCommand, message) {
345
+ const prompt = document.createElement('div')
346
+ prompt.className = 'hj-install-prompt'
347
+ prompt.innerHTML = `
348
+ <div class="hj-install-content">
349
+ <div class="hj-install-header">
350
+ <span class="hj-install-icon">\u26A0\uFE0F</span>
351
+ <span class="hj-install-title">hj CLI not installed</span>
352
+ </div>
353
+ <p class="hj-install-message">${message}</p>
354
+ <div class="hj-install-command">
355
+ <code>${installCommand}</code>
356
+ <button class="hj-copy-btn" title="Copy command">Copy</button>
357
+ </div>
358
+ <div class="hj-install-actions">
359
+ <button class="hj-install-later">Later</button>
360
+ </div>
361
+ </div>
362
+ `
363
+
364
+ prompt.querySelector('.hj-copy-btn').addEventListener('click', () => {
365
+ navigator.clipboard.writeText(installCommand)
366
+ showNotification('Command copied! Paste in Terminal and enter your password.', 5000)
367
+ })
368
+
369
+ prompt.querySelector('.hj-install-later').addEventListener('click', () => {
370
+ prompt.remove()
371
+ })
372
+
373
+ document.body.appendChild(prompt)
374
+ }