From 3122c5cba9ac4b42bd43102bfed4b0d333aa6a96 Mon Sep 17 00:00:00 2001 From: djuka Date: Wed, 18 Feb 2026 07:20:55 +0000 Subject: [PATCH] =?UTF-8?q?Pojednostavljen=20chat=20na=20jedan=20terminal,?= =?UTF-8?q?=20dodata=20notifikacija=20kad=20Claude=20zavr=C5=A1i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uklonjen multi-tab sistem — sada jedna PTY sesija po stranici. Dodat idle detection: status "Završeno", flash animacija, browser notifikacija i treptanje naslova kad je tab u pozadini. CSS premešten iz inline stilova u style.css. Dodat /api/projects endpoint i testovi za PTY sesije. Co-Authored-By: Claude Opus 4.6 --- TESTING.md | 12 + main.go | 43 ++++ pty_session_test.go | 62 +++++ static/style.css | 116 ++++++++++ templates/chat.html | 553 ++++++++++---------------------------------- ws.go | 3 +- 6 files changed, 356 insertions(+), 433 deletions(-) diff --git a/TESTING.md b/TESTING.md index 6d9717d..2159033 100644 --- a/TESTING.md +++ b/TESTING.md @@ -44,6 +44,18 @@ - [ ] Link "Promeni lozinku" vidljiv na /projects stranici - [ ] Link "Nazad na projekte" vraća na /projects +## Tabovi (više konzola) +- [ ] Otvaranje /chat/arv → jedan tab "arv" prikazan +- [ ] Klik na + → dropdown sa listom projekata +- [ ] Izbor projekta iz dropdown-a → novi tab otvoren +- [ ] Izbor istog projekta → novi tab sa zasebnom sesijom +- [ ] Klik na tab → prebacivanje terminala, fokus +- [ ] Klik na × → tab zatvoren, WS prekinut +- [ ] Poslednji tab se ne može zatvoriti (nema × dugme) +- [ ] Refresh stranice → prvi tab se ponovo konektuje sa replay-om +- [ ] Svaki tab ima nezavisan terminal (kucanje u jednom ne utiče na drugi) +- [ ] Promena teme → primenjuje se na sve tabove + ## Sesija persistence - [ ] Zatvori tab → otvori ponovo → sesija živa, poruke replayed - [ ] Idle sesija se čisti posle 30 minuta diff --git a/main.go b/main.go index d2ce13d..ef5ad77 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,7 @@ func main() { mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat))) mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler)) mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI))) + mux.Handle("GET /api/projects", AuthMiddleware(sessionMgr, http.HandlerFunc(handleProjectsAPI))) mux.Handle("GET /change-password", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChangePasswordPage))) mux.Handle("POST /change-password", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChangePassword))) @@ -116,6 +117,13 @@ func handleCreateProject(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") if err := CreateProject(cfg.ProjectsPath, name); err != nil { + // AJAX request — return JSON error + if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } projects, _ := ListProjects(cfg.ProjectsPath) data := map[string]any{ "Projects": projects, @@ -127,6 +135,13 @@ func handleCreateProject(w http.ResponseWriter, r *http.Request) { return } + // AJAX request — return JSON success + if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"name": name}) + return + } + http.Redirect(w, r, "/projects", http.StatusSeeOther) } @@ -145,10 +160,17 @@ func handleChat(w http.ResponseWriter, r *http.Request) { files = nil } + projects, err := ListProjects(cfg.ProjectsPath) + if err != nil { + log.Printf("ListProjects error: %v", err) + projects = nil + } + data := map[string]any{ "Project": project, "ProjectDir": projectDir, "Files": files, + "Projects": projects, } templates.Render(w, "chat.html", data) } @@ -197,6 +219,27 @@ func handleChangePassword(w http.ResponseWriter, r *http.Request) { templates.Render(w, "change-password.html", data) } +func handleProjectsAPI(w http.ResponseWriter, r *http.Request) { + projects, err := ListProjects(cfg.ProjectsPath) + if err != nil { + http.Error(w, "error listing projects", http.StatusInternalServerError) + return + } + + type projectItem struct { + Name string `json:"name"` + Path string `json:"path"` + } + + items := make([]projectItem, len(projects)) + for i, p := range projects { + items[i] = projectItem{Name: p.Name, Path: p.Path} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) +} + func handleFileAPI(w http.ResponseWriter, r *http.Request) { project := r.URL.Query().Get("project") relPath := r.URL.Query().Get("path") diff --git a/pty_session_test.go b/pty_session_test.go index b53f441..c599ee0 100644 --- a/pty_session_test.go +++ b/pty_session_test.go @@ -71,4 +71,66 @@ func TestPTYSessionManager(t *testing.T) { t.Error("expected empty sessions") } }) + + t.Run("different session keys create separate sessions", func(t *testing.T) { + m := &PTYSessionManager{ + sessions: make(map[string]*PTYSession), + stopCh: make(chan struct{}), + } + + // Use /tmp as a safe projectDir for testing + sess1, isNew1, err := m.GetOrCreate("arv:1", "/tmp") + if err != nil { + t.Fatalf("GetOrCreate arv:1: %v", err) + } + if !isNew1 { + t.Error("expected arv:1 to be new") + } + defer sess1.Close() + + sess2, isNew2, err := m.GetOrCreate("arv:2", "/tmp") + if err != nil { + t.Fatalf("GetOrCreate arv:2: %v", err) + } + if !isNew2 { + t.Error("expected arv:2 to be new") + } + defer sess2.Close() + + if sess1 == sess2 { + t.Error("expected different session objects for different keys") + } + + if len(m.sessions) != 2 { + t.Errorf("expected 2 sessions, got %d", len(m.sessions)) + } + }) + + t.Run("same session key returns existing session", func(t *testing.T) { + m := &PTYSessionManager{ + sessions: make(map[string]*PTYSession), + stopCh: make(chan struct{}), + } + + sess1, isNew1, err := m.GetOrCreate("arv:1", "/tmp") + if err != nil { + t.Fatalf("GetOrCreate: %v", err) + } + if !isNew1 { + t.Error("expected first call to be new") + } + defer sess1.Close() + + sess2, isNew2, err := m.GetOrCreate("arv:1", "/tmp") + if err != nil { + t.Fatalf("GetOrCreate second: %v", err) + } + if isNew2 { + t.Error("expected second call to reuse existing session") + } + + if sess1 != sess2 { + t.Error("expected same session object for same key") + } + }) } diff --git a/static/style.css b/static/style.css index 5a3db51..8d3fe60 100644 --- a/static/style.css +++ b/static/style.css @@ -665,6 +665,122 @@ a:hover { gap: 0.5rem; } +/* Tab bar */ +.tab-bar { + display: flex; + align-items: center; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 0 0.25rem; + flex-shrink: 0; + overflow-x: auto; + gap: 2px; + min-height: 32px; +} + +.tab-bar::-webkit-scrollbar { height: 0; } + +.tab { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0.5rem; + font-size: 0.7rem; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + color: var(--text-muted); + cursor: pointer; + border: 1px solid transparent; + border-bottom: none; + border-radius: 4px 4px 0 0; + white-space: nowrap; + user-select: none; + transition: color 0.15s, background 0.15s; +} + +.tab:hover { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.03); +} + +.tab.active { + color: var(--text-primary); + background: var(--bg-primary); + border-color: var(--border); +} + +.tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 0.65rem; + border-radius: 3px; + color: var(--text-muted); + transition: background 0.1s, color 0.1s; +} + +.tab-close:hover { + background: rgba(244, 67, 54, 0.3); + color: var(--error); +} + +.tab-add { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + font-size: 0.85rem; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + border: none; + background: none; + font-family: inherit; + transition: color 0.15s, background 0.15s; + flex-shrink: 0; +} + +.tab-add:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); +} + +/* Tab dropdown for project selection */ +.tab-dropdown { + position: absolute; + top: 100%; + right: 0; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + min-width: 180px; + max-height: 300px; + overflow-y: auto; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.tab-dropdown-item { + display: block; + width: 100%; + padding: 0.4rem 0.75rem; + font-size: 0.75rem; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + color: var(--text-secondary); + background: none; + border: none; + text-align: left; + cursor: pointer; + transition: background 0.1s, color 0.1s; +} + +.tab-dropdown-item:hover { + background: rgba(233, 69, 96, 0.15); + color: var(--text-primary); +} + /* Hidden */ .hidden { display: none !important; diff --git a/templates/chat.html b/templates/chat.html index d96712f..42be5e9 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -118,6 +118,9 @@ .terminal-header .title { color: var(--accent); font-weight: 600; } .terminal-header .status { font-size: 0.7rem; color: var(--text-muted); } .terminal-header .status.connected { color: #4caf50; } + .terminal-header .status.done { color: #ff9800; } + .done-flash { animation: flash-border 0.6s ease-out; } + @keyframes flash-border { 0% { border-color: #ff9800; } 100% { border-color: var(--border); } } .terminal-header .controls { display: flex; align-items: center; gap: 0.6rem; } .terminal-header select { background: var(--bg-primary); @@ -143,98 +146,8 @@ } .terminal-header button:hover { border-color: var(--accent); color: var(--text-primary); } - /* Tab bar */ - .tab-bar { - display: flex; - align-items: center; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); - padding: 0 0.25rem; - flex-shrink: 0; - overflow-x: auto; - gap: 2px; - min-height: 30px; - } - .tab-bar::-webkit-scrollbar { height: 0; } - .tab { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.2rem 0.5rem; - font-size: 0.7rem; - color: var(--text-muted); - cursor: pointer; - border: 1px solid transparent; - border-bottom: none; - border-radius: 4px 4px 0 0; - white-space: nowrap; - user-select: none; - transition: color 0.15s, background 0.15s; - } - .tab:hover { color: var(--text-secondary); background: rgba(255,255,255,0.03); } - .tab.active { color: var(--text-primary); background: var(--bg-primary); border-color: var(--border); } - .tab-close { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - font-size: 0.65rem; - border-radius: 3px; - color: var(--text-muted); - transition: background 0.1s, color 0.1s; - } - .tab-close:hover { background: rgba(244,67,54,0.3); color: var(--error); } - .tab-add-wrap { position: relative; flex-shrink: 0; } - .tab-add { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - font-size: 0.85rem; - color: var(--text-muted); - cursor: pointer; - border-radius: 4px; - border: none; - background: none; - font-family: inherit; - transition: color 0.15s, background 0.15s; - } - .tab-add:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); } - .tab-dropdown { - position: absolute; - top: 100%; - left: 0; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 6px; - min-width: 180px; - max-height: 300px; - overflow-y: auto; - z-index: 100; - box-shadow: 0 4px 12px rgba(0,0,0,0.4); - } - .tab-dropdown.hidden { display: none; } - .tab-dropdown-item { - display: block; - width: 100%; - padding: 0.4rem 0.75rem; - font-size: 0.75rem; - font-family: inherit; - color: var(--text-secondary); - background: none; - border: none; - text-align: left; - cursor: pointer; - transition: background 0.1s, color 0.1s; - } - .tab-dropdown-item:hover { background: rgba(233,69,96,0.15); color: var(--text-primary); } - - /* Terminal containers */ - #terminals { flex: 1; overflow: hidden; position: relative; } - .term-pane { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: none; } - .term-pane.active { display: block; } + /* Terminal container */ + #terminal-container { flex: 1; overflow: hidden; } .xterm { height: 100%; } /* File viewer overlay */ @@ -301,39 +214,6 @@ background: rgba(233,69,96,0.1); font-weight: 600; } - .sidebar-add-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - font-size: 0.8rem; - color: var(--text-muted); - cursor: pointer; - border-radius: 3px; - transition: color 0.15s, background 0.15s; - line-height: 1; - } - .sidebar-add-btn:hover { color: var(--accent); background: rgba(233,69,96,0.15); } - - /* Create project modal */ - .create-project-modal { - position: fixed; top: 0; left: 0; width: 100%; height: 100%; - background: rgba(0,0,0,0.5); display: flex; align-items: center; - justify-content: center; z-index: 300; - } - .create-project-box { - background: var(--bg-secondary); border: 1px solid var(--border); - border-radius: 8px; padding: 1rem; width: 260px; - box-shadow: 0 4px 16px rgba(0,0,0,0.4); - } - .create-project-box h3 { color: var(--accent); font-size: 0.85rem; margin-bottom: 0.6rem; } - .create-project-box input { - width: 100%; padding: 0.35rem 0.5rem; background: var(--bg-primary); - border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); - font-size: 0.75rem; font-family: inherit; outline: none; - } - .create-project-box input:focus { border-color: var(--accent); } /* Scrollbar */ ::-webkit-scrollbar { width: 5px; } @@ -352,10 +232,7 @@ - - -
@@ -416,14 +277,7 @@
-
- -
- - -
-
-
+
@@ -522,272 +376,154 @@ document.body.style.background = theme.background; document.querySelector('.main').style.background = theme.background; localStorage.setItem('terminal-theme', name); - // Apply to all open tabs - tabs.forEach(function(tab) { - tab.term.options.theme = theme; - }); + term.options.theme = theme; } - // ── Tab system ────────────────────────────────────── - var tabs = []; - var activeTabId = null; - var nextTabId = 1; + // ── Terminal ───────────────────────────────────────── var statusEl = document.getElementById('ws-status'); - var tabBarEl = document.getElementById('tab-bar'); - var terminalsEl = document.getElementById('terminals'); - var tabAddBtn = document.getElementById('tab-add-btn'); - var tabDropdown = document.getElementById('tab-dropdown'); - var projectsCache = null; + var containerEl = document.getElementById('terminal-container'); + var ws = null; + var reconnectTimer = null; + var originalTitle = document.title; + var idleTimer = null; + var isStreaming = false; + var titleBlinkTimer = null; - function createTerminal() { - var theme = THEMES[savedTheme] || THEMES.dark; - var term = new Terminal({ - cursorBlink: true, - cursorStyle: 'block', - cursorInactiveStyle: 'outline', - fontSize: 14, - fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace", - theme: theme, - allowProposedApi: true, - scrollback: 10000, - convertEol: false, - drawBoldTextInBrightColors: true, - }); - var fitAddon = new FitAddon.FitAddon(); - term.loadAddon(fitAddon); - term.loadAddon(new WebLinksAddon.WebLinksAddon()); - return { term: term, fitAddon: fitAddon }; - } + var theme = THEMES[savedTheme] || THEMES.dark; + var term = new Terminal({ + cursorBlink: true, + cursorStyle: 'block', + cursorInactiveStyle: 'outline', + fontSize: 14, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace", + theme: theme, + allowProposedApi: true, + scrollback: 10000, + convertEol: false, + drawBoldTextInBrightColors: true, + }); + var fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.loadAddon(new WebLinksAddon.WebLinksAddon()); + term.open(containerEl); - function connectTab(tab) { + term.onData(function(data) { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); + // User typed — reset idle detection and status + isStreaming = false; + clearTimeout(idleTimer); + if (ws && ws.readyState === WebSocket.OPEN) { + statusEl.textContent = 'Povezan'; + statusEl.className = 'status connected'; + } + }); + term.onResize(function(size) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows })); + } + }); + containerEl.addEventListener('click', function() { term.focus(); }); + + function connect() { var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - var url = proto + '//' + location.host + '/ws?project=' + encodeURIComponent(tab.project) + - '&project_dir=' + encodeURIComponent(tab.projectDir) + - '&tab=' + encodeURIComponent(tab.id); - var ws = new WebSocket(url); + var url = proto + '//' + location.host + '/ws?project=' + encodeURIComponent(PAGE_PROJECT) + + '&project_dir=' + encodeURIComponent(PAGE_PROJECT_DIR); + ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; - tab.ws = ws; ws.onopen = function() { - if (activeTabId === tab.id) { - statusEl.textContent = 'Povezan'; - statusEl.className = 'status connected'; - } - ws.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows })); - if (activeTabId === tab.id) tab.term.focus(); + statusEl.textContent = 'Povezan'; + statusEl.className = 'status connected'; + ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); + term.focus(); }; ws.onmessage = function(event) { if (event.data instanceof ArrayBuffer) { - tab.term.write(new Uint8Array(event.data)); + term.write(new Uint8Array(event.data)); } else { - tab.term.write(event.data); + term.write(event.data); } + // Idle detection — reset timer on each output + isStreaming = true; + clearTimeout(idleTimer); + idleTimer = setTimeout(onOutputIdle, 3000); }; ws.onclose = function() { - if (activeTabId === tab.id) { - statusEl.textContent = 'Nepovezan'; - statusEl.className = 'status'; - } - // Reconnect only if tab still exists - if (tabs.find(function(t) { return t.id === tab.id; })) { - tab.reconnectTimer = setTimeout(function() { connectTab(tab); }, 2000); - } + statusEl.textContent = 'Nepovezan'; + statusEl.className = 'status'; + reconnectTimer = setTimeout(connect, 2000); }; ws.onerror = function() { - if (activeTabId === tab.id) { - statusEl.textContent = 'Greška'; - statusEl.className = 'status'; - } + statusEl.textContent = 'Greška'; + statusEl.className = 'status'; }; } - function addTab(project, projectDir) { - var tabId = String(nextTabId++); - var t = createTerminal(); + // ── Idle detection & notifications ─────────────────── + function onOutputIdle() { + if (!isStreaming) return; + isStreaming = false; - // Create pane - var pane = document.createElement('div'); - pane.className = 'term-pane'; - pane.id = 'pane-' + tabId; - terminalsEl.appendChild(pane); - t.term.open(pane); + // Visual indicator — always + statusEl.textContent = 'Završeno'; + statusEl.className = 'status done'; + var header = document.querySelector('.terminal-header'); + header.classList.remove('done-flash'); + void header.offsetWidth; // force reflow + header.classList.add('done-flash'); - var tab = { - id: tabId, - project: project, - projectDir: projectDir, - term: t.term, - fitAddon: t.fitAddon, - ws: null, - reconnectTimer: null, - container: pane - }; - - // Wire input - t.term.onData(function(data) { - if (tab.ws && tab.ws.readyState === WebSocket.OPEN) tab.ws.send(data); - }); - t.term.onResize(function(size) { - if (tab.ws && tab.ws.readyState === WebSocket.OPEN) { - tab.ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows })); - } - }); - - // Click on pane gives focus - pane.addEventListener('click', function() { tab.term.focus(); }); - - tabs.push(tab); - renderTabs(); - switchTab(tabId); - connectTab(tab); - - return tab; - } - - function switchTab(tabId) { - activeTabId = tabId; - tabs.forEach(function(tab) { - if (tab.id === tabId) { - tab.container.classList.add('active'); - } else { - tab.container.classList.remove('active'); - } - }); - renderTabs(); - var active = tabs.find(function(t) { return t.id === tabId; }); - if (active) { - setTimeout(function() { - active.fitAddon.fit(); - active.term.focus(); - }, 50); - // Update status - if (active.ws && active.ws.readyState === WebSocket.OPEN) { - statusEl.textContent = 'Povezan'; - statusEl.className = 'status connected'; - } else { - statusEl.textContent = 'Nepovezan'; - statusEl.className = 'status'; - } - } - } - - function closeTab(tabId) { - if (tabs.length <= 1) return; // Don't close last tab - var idx = tabs.findIndex(function(t) { return t.id === tabId; }); - if (idx === -1) return; - var tab = tabs[idx]; - - // Clean up - clearTimeout(tab.reconnectTimer); - if (tab.ws) { - tab.ws.onclose = null; // Prevent reconnect - tab.ws.close(); - } - tab.term.dispose(); - tab.container.remove(); - tabs.splice(idx, 1); - - // Switch to neighbor if active was closed - if (activeTabId === tabId) { - var newIdx = Math.min(idx, tabs.length - 1); - switchTab(tabs[newIdx].id); - } else { - renderTabs(); - } - } - - function renderTabs() { - // Remove old tab elements (keep the add button wrapper) - var addWrap = document.querySelector('.tab-add-wrap'); - var oldTabs = tabBarEl.querySelectorAll('.tab'); - oldTabs.forEach(function(el) { el.remove(); }); - - tabs.forEach(function(tab) { - var el = document.createElement('div'); - el.className = 'tab' + (tab.id === activeTabId ? ' active' : ''); - - var label = document.createElement('span'); - label.textContent = tab.project; - label.addEventListener('click', function(e) { - e.stopPropagation(); - switchTab(tab.id); - }); - el.appendChild(label); - - if (tabs.length > 1) { - var closeBtn = document.createElement('span'); - closeBtn.className = 'tab-close'; - closeBtn.textContent = '\u00d7'; - closeBtn.title = 'Zatvori tab'; - closeBtn.addEventListener('click', function(e) { - e.stopPropagation(); - closeTab(tab.id); + // Background tab — browser notification + title blink + if (document.hidden) { + if (Notification.permission === 'granted') { + var n = new Notification('Claude — ' + PAGE_PROJECT, { + body: 'Odgovor je završen', + tag: 'claude-done' }); - el.appendChild(closeBtn); + n.onclick = function() { window.focus(); n.close(); }; } - - tabBarEl.insertBefore(el, addWrap); - }); + startTitleBlink(); + } } - // ── Add tab dropdown ──────────────────────────────── - tabAddBtn.addEventListener('click', function(e) { - e.stopPropagation(); - if (!tabDropdown.classList.contains('hidden')) { - tabDropdown.classList.add('hidden'); - return; - } - // Fetch projects and show dropdown - if (projectsCache) { - showDropdown(projectsCache); - } else { - fetch('/api/projects') - .then(function(r) { return r.json(); }) - .then(function(data) { - projectsCache = data; - showDropdown(data); - }) - .catch(function() { - // Fallback: just add tab for current project - addTab(PAGE_PROJECT, PAGE_PROJECT_DIR); - }); - } - }); - - function showDropdown(projects) { - tabDropdown.innerHTML = ''; - projects.forEach(function(p) { - var btn = document.createElement('button'); - btn.className = 'tab-dropdown-item'; - btn.textContent = p.name; - btn.addEventListener('click', function() { - tabDropdown.classList.add('hidden'); - addTab(p.name, p.path); - }); - tabDropdown.appendChild(btn); - }); - tabDropdown.classList.remove('hidden'); + // Request notification permission + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); } - // Close dropdown when clicking outside - document.addEventListener('click', function() { - tabDropdown.classList.add('hidden'); + // Title blink when tab is in background + function startTitleBlink() { + if (titleBlinkTimer) return; + var on = true; + titleBlinkTimer = setInterval(function() { + document.title = on ? '✔ Završeno — ' + PAGE_PROJECT : originalTitle; + on = !on; + }, 1000); + } + + function stopTitleBlink() { + if (titleBlinkTimer) { + clearInterval(titleBlinkTimer); + titleBlinkTimer = null; + document.title = originalTitle; + } + } + + // Stop blinking when tab gets focus + document.addEventListener('visibilitychange', function() { + if (!document.hidden) stopTitleBlink(); }); + window.addEventListener('focus', stopTitleBlink); // ── Window resize ─────────────────────────────────── - window.addEventListener('resize', function() { - var active = tabs.find(function(t) { return t.id === activeTabId; }); - if (active) active.fitAddon.fit(); - }); + window.addEventListener('resize', function() { fitAddon.fit(); }); - // ── Apply initial theme ───────────────────────────── + // ── Apply initial theme and connect ────────────────── applyTheme(savedTheme); - - // ── Create first tab ──────────────────────────────── - addTab(PAGE_PROJECT, PAGE_PROJECT_DIR); + setTimeout(function() { fitAddon.fit(); }, 50); + connect(); // ── Sidebar ───────────────────────────────────────── var sidebar = document.getElementById('sidebar'); @@ -797,10 +533,7 @@ function toggleSidebar() { sidebar.classList.toggle('collapsed'); localStorage.setItem('sidebar-visible', !sidebar.classList.contains('collapsed')); - setTimeout(function() { - var active = tabs.find(function(t) { return t.id === activeTabId; }); - if (active) active.fitAddon.fit(); - }, 200); + setTimeout(function() { fitAddon.fit(); }, 200); } // ── File viewer ───────────────────────────────────── @@ -817,56 +550,12 @@ function closeFileViewer() { document.getElementById('file-viewer').classList.add('hidden'); - var active = tabs.find(function(t) { return t.id === activeTabId; }); - if (active) active.term.focus(); + term.focus(); } document.addEventListener('keydown', function(e) { - if (e.key === 'Escape') { - closeFileViewer(); - document.getElementById('createProjectModal').classList.add('hidden'); - } + if (e.key === 'Escape') closeFileViewer(); }); - - // ── Create project ─────────────────────────────────── - function showCreateProjectModal() { - document.getElementById('createProjectModal').classList.remove('hidden'); - setTimeout(function() { - document.getElementById('newProjectName').focus(); - }, 50); - } - - function createProject(e) { - e.preventDefault(); - var name = document.getElementById('newProjectName').value.trim(); - var errEl = document.getElementById('createProjectError'); - if (!name) return false; - - var form = new FormData(); - form.append('name', name); - - fetch('/projects/create', { - method: 'POST', - body: form, - headers: { 'X-Requested-With': 'XMLHttpRequest' } - }) - .then(function(r) { - return r.json().then(function(data) { - if (r.ok) { - projectsCache = null; // Invalidate cache - window.location.href = '/chat/' + encodeURIComponent(name); - } else { - errEl.textContent = data.error || 'Greška pri kreiranju'; - errEl.style.display = 'block'; - } - }); - }) - .catch(function() { - errEl.textContent = 'Greška pri kreiranju'; - errEl.style.display = 'block'; - }); - return false; - } diff --git a/ws.go b/ws.go index 49afd56..82523f8 100644 --- a/ws.go +++ b/ws.go @@ -39,6 +39,7 @@ func (h *TerminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "missing project params", http.StatusBadRequest) return } + sessionKey := project conn, err := upgrader.Upgrade(w, r, nil) if err != nil { @@ -47,7 +48,7 @@ func (h *TerminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } defer conn.Close() - sess, isNew, err := h.ptyMgr.GetOrCreate(project, projectDir) + sess, isNew, err := h.ptyMgr.GetOrCreate(sessionKey, projectDir) if err != nil { log.Printf("PTY session error: %v", err) conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("\r\nGreška: %v\r\n", err)))