Pojednostavljen chat na jedan terminal, dodata notifikacija kad Claude završi
Some checks failed
Tests / unit-tests (push) Failing after 22s
Some checks failed
Tests / unit-tests (push) Failing after 22s
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 <noreply@anthropic.com>
This commit is contained in:
parent
e60e574287
commit
3122c5cba9
12
TESTING.md
12
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
|
||||
|
||||
43
main.go
43
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")
|
||||
|
||||
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
116
static/style.css
116
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;
|
||||
|
||||
@ -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 @@
|
||||
|
||||
<!-- Projekti -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
Projekti
|
||||
<span class="sidebar-add-btn" onclick="showCreateProjectModal()" title="Novi projekat">+</span>
|
||||
</div>
|
||||
<div class="sidebar-section-title">Projekti</div>
|
||||
</div>
|
||||
<div class="sidebar-files sidebar-projects-list">
|
||||
{{range .Projects}}
|
||||
@ -379,22 +256,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal za kreiranje projekta -->
|
||||
<div id="createProjectModal" class="create-project-modal hidden" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="create-project-box">
|
||||
<h3>Novi projekat</h3>
|
||||
<form id="createProjectForm" onsubmit="return createProject(event)">
|
||||
<input type="text" id="newProjectName" placeholder="ime-projekta" required>
|
||||
<p style="color:var(--text-muted);font-size:0.65rem;margin:0.3rem 0;">Slova, brojevi, - i _</p>
|
||||
<div style="display:flex;gap:0.4rem;justify-content:flex-end;">
|
||||
<button type="button" onclick="document.getElementById('createProjectModal').classList.add('hidden')" style="background:var(--bg-primary);color:var(--text-secondary);border:1px solid var(--border);border-radius:4px;padding:0.3rem 0.6rem;font-size:0.7rem;cursor:pointer;font-family:inherit;">Otkaži</button>
|
||||
<button type="submit" style="background:var(--accent);color:#fff;border:none;border-radius:4px;padding:0.3rem 0.6rem;font-size:0.7rem;cursor:pointer;font-family:inherit;">Kreiraj</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="createProjectError" style="color:var(--error);font-size:0.65rem;margin-top:0.3rem;display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main terminal area -->
|
||||
<div class="main">
|
||||
<div class="terminal-header">
|
||||
@ -416,14 +277,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-bar" id="tab-bar">
|
||||
<!-- Tabs rendered by JS -->
|
||||
<div class="tab-add-wrap">
|
||||
<button class="tab-add" id="tab-add-btn" title="Novi tab">+</button>
|
||||
<div class="tab-dropdown hidden" id="tab-dropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminals"></div>
|
||||
<div id="terminal-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- File viewer overlay -->
|
||||
@ -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;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
3
ws.go
3
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)))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user