diff --git a/frontend/package.json b/frontend/package.json index 478c986..289d18e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "bundle:novnc": "node scripts/bundle-novnc.js", "build": "node scripts/build.js", "dev": "node scripts/build.js --watch", - "test": "node scripts/test-session-lifecycle.js && node scripts/test-regressions.js", + "test": "node scripts/test-session-lifecycle.js && node scripts/test-irc.js && node scripts/test-regressions.js", "postinstall": "npm run install:assets && npm run bundle:novnc" }, "dependencies": { diff --git a/frontend/scripts/test-irc.js b/frontend/scripts/test-irc.js new file mode 100644 index 0000000..7433059 --- /dev/null +++ b/frontend/scripts/test-irc.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const frontendRoot = path.resolve(__dirname, ".."); +const source = fs.readFileSync(path.join(frontendRoot, "src/js/irc.js"), "utf8"); +const context = { + window: { + NexusTerminal: { + createElement: () => { + throw new Error("formatting tests must not create DOM elements"); + }, + }, + }, +}; +vm.createContext(context); +vm.runInContext(source, context, { filename: "src/js/irc.js" }); + +const formatting = context.window.NexusTerminal.IRCFormatting; +assert.ok(formatting, "IRC formatting helpers should be exported"); + +assert.strictEqual( + formatting.formatText("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"), + "plain \x1b[1mbold\x1b[22m \x1b[3mitalic\x1b[23m \x1b[4munderlined\x1b[24m", +); +assert.strictEqual( + formatting.formatText("\x0304red\x03 default"), + "\x1b[38;5;9mred\x1b[39;49m default", +); +assert.strictEqual( + formatting.formatText("\x0352extended \x0303,01matrix"), + "\x1b[38;5;196mextended \x1b[38;5;2m\x1b[48;5;0mmatrix", +); +assert.strictEqual( + formatting.formatText("\x04ff8800orange\x04 default"), + "\x1b[38;2;255;136;0morange\x1b[39;49m default", +); +assert.strictEqual( + formatting.formatText("\x0304,comma"), + "\x1b[38;5;9m,comma", + "a colour code without a background must leave its comma visible", +); +assert.strictEqual( + formatting.plainText("\x02bold\x02 \x0304red\x03"), + "bold red", +); +assert.strictEqual( + formatting.formatText("before\x1b[31munsafe\x1b]0;title\x07after"), + "beforeunsafeafter", + "untrusted ANSI and OSC sequences must not reach xterm", +); + +const message = formatting.formatEvent( + { + type: "message", + from: "alice", + text: "hello \x02world\x02", + }, + "12:34:56", +); +assert.ok(message.includes("[12:34:56]")); +assert.ok(message.includes("\x1b[1mworld\x1b[22m")); +assert.ok(message.includes("\x1b[1m\x1b[38;5;"), "nicknames should be bold and coloured"); +assert.ok(message.endsWith("\x1b[0m"), "every line must reset terminal styling"); + +const action = formatting.formatEvent( + { type: "action", from: "bob", text: "waves" }, + "01:02:03", +); +assert.ok(action.includes("\x1b[3m"), "actions should be italicised"); + +console.log("IRC formatting regression tests passed."); diff --git a/frontend/scripts/test-regressions.js b/frontend/scripts/test-regressions.js index e937850..4126e7a 100644 --- a/frontend/scripts/test-regressions.js +++ b/frontend/scripts/test-regressions.js @@ -240,6 +240,16 @@ assertContains( "window.NexusTerminal.IRCViewer?.destroyViewer(sessionId)", "session teardown must dispose IRC terminals and resize observers", ); +assertContains( + "frontend/src/js/irc.js", + "wailsService.IRCRooms(this.sessionID)", + "IRC viewers created after connection must hydrate the already joined rooms", +); +assertContains( + "internal/sessions/manager.go", + "GetIRCLastRooms(profile.ID)", + "saved IRC profiles must restore the channels open in their last session", +); assertContains( "frontend/src/js/app.js", "this.recordings.cleanup(sessionId)", diff --git a/frontend/src/js/irc.js b/frontend/src/js/irc.js index 3c21a71..eb6ebf1 100644 --- a/frontend/src/js/irc.js +++ b/frontend/src/js/irc.js @@ -11,6 +11,188 @@ const SERVER_TARGET = "server"; const MAX_LINES_PER_TARGET = 2000; const MAX_LISTED_ROOMS = 200; + const ANSI_RESET = "\x1b[0m"; + const ANSI_DIM = "\x1b[2m"; + const ANSI_BOLD = "\x1b[1m"; + const ANSI_ITALIC = "\x1b[3m"; + const ANSI_UNDERLINE = "\x1b[4m"; + const ANSI_REVERSE = "\x1b[7m"; + const ANSI_STRIKE = "\x1b[9m"; + const ANSI_GREY = "\x1b[38;5;244m"; + const ANSI_RED = "\x1b[38;5;203m"; + const ANSI_GREEN = "\x1b[38;5;83m"; + const ANSI_YELLOW = "\x1b[38;5;221m"; + const ANSI_CYAN = "\x1b[38;5;81m"; + const ANSI_MAGENTA = "\x1b[38;5;177m"; + const NICK_COLOURS = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220]; + + // IRC colours 00-98 mapped to the xterm 256-colour palette described by + // the modern IRC formatting specification. Colour 99 means terminal default. + const IRC_ANSI_COLOURS = [ + 15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7, + 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, + 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, + 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, + 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, + 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, + 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, + 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231, + ]; + + function stripTerminalEscapes(value) { + return String(value || "") + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\x1b./g, "") + .replace(/[\x80-\x9f]/g, ""); + } + + function readDecimalColour(input, offset) { + const match = input.slice(offset).match(/^\d{1,2}/); + if (!match) return null; + return { value: Number(match[0]), length: match[0].length }; + } + + function readHexColour(input, offset) { + const value = input.slice(offset, offset + 6); + if (!/^[0-9a-f]{6}$/i.test(value)) return null; + return { value, length: 6 }; + } + + function indexedColour(code, background = false) { + if (code === 99 || code < 0 || code >= IRC_ANSI_COLOURS.length) { + return `\x1b[${background ? 49 : 39}m`; + } + return `\x1b[${background ? 48 : 38};5;${IRC_ANSI_COLOURS[code]}m`; + } + + function hexColour(value, background = false) { + const red = parseInt(value.slice(0, 2), 16); + const green = parseInt(value.slice(2, 4), 16); + const blue = parseInt(value.slice(4, 6), 16); + return `\x1b[${background ? 48 : 38};2;${red};${green};${blue}m`; + } + + function formatIRCText(value) { + const input = stripTerminalEscapes(value); + const toggles = { + "\x02": [ANSI_BOLD, "\x1b[22m", false], + "\x1d": [ANSI_ITALIC, "\x1b[23m", false], + "\x1e": [ANSI_STRIKE, "\x1b[29m", false], + "\x1f": [ANSI_UNDERLINE, "\x1b[24m", false], + "\x16": [ANSI_REVERSE, "\x1b[27m", false], + }; + let output = ""; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (toggles[char]) { + const toggle = toggles[char]; + toggle[2] = !toggle[2]; + output += toggle[toggle[2] ? 0 : 1]; + continue; + } + if (char === "\x0f") { + output += ANSI_RESET; + for (const toggle of Object.values(toggles)) toggle[2] = false; + continue; + } + if (char === "\x03" || char === "\x04") { + const readColour = char === "\x03" ? readDecimalColour : readHexColour; + const foreground = readColour(input, index + 1); + if (!foreground) { + output += "\x1b[39;49m"; + continue; + } + index += foreground.length; + output += + char === "\x03" + ? indexedColour(foreground.value) + : hexColour(foreground.value); + + if (input[index + 1] !== ",") continue; + const background = readColour(input, index + 2); + if (!background) { + output += ","; + index += 1; + continue; + } + index += background.length + 1; + output += + char === "\x03" + ? indexedColour(background.value, true) + : hexColour(background.value, true); + continue; + } + // xterm is already monospace, so the IRC monospace toggle needs no SGR. + if (char === "\x11") continue; + const code = char.charCodeAt(0); + if (code < 0x20 || code === 0x7f) continue; + output += char; + } + return output; + } + + function plainIRCText(value) { + return formatIRCText(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); + } + + function nickColour(nick) { + let hash = 0; + for (const char of String(nick || "")) { + hash = (hash * 31 + char.codePointAt(0)) >>> 0; + } + return `\x1b[38;5;${NICK_COLOURS[hash % NICK_COLOURS.length]}m`; + } + + function formatIRCEvent(event, time) { + const from = plainIRCText(event?.from || ""); + const text = formatIRCText(event?.text || ""); + const subject = plainIRCText(event?.command || ""); + const timestamp = `${ANSI_DIM}${ANSI_GREY}[${time}]${ANSI_RESET}`; + const styledNick = from + ? `${ANSI_BOLD}${nickColour(from)}${from}${ANSI_RESET}` + : ""; + let body; + + switch (event?.type) { + case "message": + body = `${ANSI_GREY}<${ANSI_RESET}${styledNick}${ANSI_GREY}>${ANSI_RESET} ${text}`; + break; + case "action": + body = `${ANSI_MAGENTA}${ANSI_ITALIC}* ${styledNick}${ANSI_MAGENTA}${ANSI_ITALIC} ${text}`; + break; + case "notice": + body = `${ANSI_YELLOW}-${styledNick || "notice"}${ANSI_YELLOW}-${ANSI_RESET} ${text}`; + break; + case "join": + body = `${ANSI_GREEN}-->${ANSI_RESET} ${styledNick || "user"} joined`; + break; + case "part": + body = `${ANSI_RED}<--${ANSI_RESET} ${styledNick || "user"} left${text ? ` (${text})` : ""}`; + break; + case "kick": + body = `${ANSI_RED}<--${ANSI_RESET} ${ANSI_BOLD}${subject || "user"}${ANSI_RESET} was kicked by ${styledNick || "server"}${text ? ` (${text})` : ""}`; + break; + case "quit": + body = `${ANSI_RED}<--${ANSI_RESET} ${styledNick || "user"} quit${text ? ` (${text})` : ""}`; + break; + case "nick": + body = `${styledNick || "user"} is now known as ${ANSI_BOLD}${plainIRCText(event?.text || "")}${ANSI_RESET}`; + break; + case "topic": + body = `${ANSI_CYAN}${ANSI_BOLD}topic:${ANSI_RESET} ${text}`; + break; + case "error": + body = `${ANSI_RED}${ANSI_BOLD}error:${ANSI_RESET} ${text}`; + break; + case "system": + default: + body = `${ANSI_CYAN}${text}`; + break; + } + return `${timestamp} ${body}${ANSI_RESET}`; + } class IRCViewer { constructor() { @@ -68,6 +250,7 @@ this.roomList = []; this.roomFilter = ""; this.unreadTargets = new Set(); + this.destroyed = false; this.tabsEl = null; this.termHost = null; @@ -131,6 +314,7 @@ text: "Connecting...", timestamp: Date.now(), }); + void this._refreshJoinedRooms(); } handleEvent(event) { @@ -170,7 +354,7 @@ this._renderTabs(); } - if (["join", "part", "topic"].includes(event.type)) { + if (["join", "part", "kick", "topic"].includes(event.type)) { this._renderRooms(); } } @@ -187,6 +371,7 @@ } destroy() { + this.destroyed = true; if (this.observer) { this.observer.disconnect(); this.observer = null; @@ -350,7 +535,7 @@ for (const room of this.joinedRooms) { const row = createElement("button", "irc-room-row", { type: "button", - title: room.topic || room.name, + title: plainIRCText(room.topic || room.name), }); row.classList.toggle("active", room.name === this.activeTarget); row.appendChild(createElement("span", "irc-room-name", { textContent: room.name })); @@ -420,7 +605,7 @@ const row = createElement("div", "irc-listed-room"); const meta = createElement("button", "irc-listed-room-main", { type: "button", - title: room.topic || room.name, + title: plainIRCText(room.topic || room.name), }); meta.appendChild( createElement("span", "irc-listed-room-name", { @@ -429,7 +614,7 @@ ); meta.appendChild( createElement("span", "irc-listed-room-topic", { - textContent: room.topic || "", + textContent: plainIRCText(room.topic || ""), }), ); meta.addEventListener("click", () => this._joinRoom(room.name)); @@ -507,6 +692,16 @@ } } + async _refreshJoinedRooms() { + try { + const rooms = await window.NexusTerminal.wailsService.IRCRooms(this.sessionID); + if (this.destroyed) return; + this.handleEvent({ type: "rooms", rooms: Array.isArray(rooms) ? rooms : [] }); + } catch (_) { + // The session may have closed while the viewer was being created. + } + } + _completeMention() { if (!this.inputEl || !this._isRoom(this.activeTarget)) return false; const start = this.inputEl.selectionStart || 0; @@ -560,32 +755,7 @@ } _formatEvent(event) { - const time = this._time(event.timestamp); - const from = this._sanitize(event.from || ""); - const text = this._sanitize(event.text || ""); - switch (event.type) { - case "message": - return `[${time}] <${from}> ${text}`; - case "action": - return `[${time}] * ${from} ${text}`; - case "notice": - return `[${time}] -${from || "notice"}- ${text}`; - case "join": - return `[${time}] --> ${from || "user"} joined`; - case "part": - return `[${time}] <-- ${from || "user"} left${text ? ` (${text})` : ""}`; - case "quit": - return `[${time}] <-- ${from || "user"} quit${text ? ` (${text})` : ""}`; - case "nick": - return `[${time}] ${from || "user"} is now known as ${text}`; - case "topic": - return `[${time}] topic: ${text}`; - case "error": - return `[${time}] error: ${text}`; - case "system": - default: - return `[${time}] ${text}`; - } + return formatIRCEvent(event, this._time(event.timestamp)); } _time(value) { @@ -597,11 +767,7 @@ } _sanitize(value) { - return String(value || "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") - .replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "") - .replace(/[\x02\x0f\x16\x1d\x1f]/g, ""); + return plainIRCText(value); } _displayNick(user) { @@ -620,5 +786,10 @@ } window.NexusTerminal = window.NexusTerminal || {}; + window.NexusTerminal.IRCFormatting = Object.freeze({ + formatEvent: formatIRCEvent, + formatText: formatIRCText, + plainText: plainIRCText, + }); window.NexusTerminal.IRCViewer = new IRCViewer(); })(); diff --git a/internal/ircclient/ircclient.go b/internal/ircclient/ircclient.go index 3660584..4a75a27 100644 --- a/internal/ircclient/ircclient.go +++ b/internal/ircclient/ircclient.go @@ -52,6 +52,7 @@ type Config struct { OnEvent func(Event) OnError func(error) OnClose func() + OnRoomsChanged func([]string) } // Event is a typed IRC event suitable for JSON delivery to the frontend. @@ -85,6 +86,7 @@ type Client struct { connected bool closing bool listRooms []RoomInfo + roomNames []string } // New creates an IRC client wrapper. @@ -406,10 +408,14 @@ func (c *Client) Rooms() []RoomInfo { if c.client == nil { return nil } - channels := c.client.Channels() - rooms := make([]RoomInfo, 0, len(channels)) - for _, ch := range channels { + c.mu.Lock() + roomNames := append([]string{}, c.roomNames...) + c.mu.Unlock() + rooms := make([]RoomInfo, 0, len(roomNames)) + for _, roomName := range roomNames { + ch := c.client.LookupChannel(roomName) if ch == nil { + rooms = append(rooms, RoomInfo{Name: roomName}) continue } rooms = append(rooms, RoomInfo{ @@ -445,6 +451,7 @@ func (c *Client) registerHandlers() { c.client.Handlers.Add(girc.NOTICE, c.handleNotice) c.client.Handlers.Add(girc.JOIN, c.handleJoin) c.client.Handlers.Add(girc.PART, c.handlePart) + c.client.Handlers.Add(girc.KICK, c.handleKick) c.client.Handlers.Add(girc.QUIT, c.handleQuit) c.client.Handlers.Add(girc.NICK, c.handleNick) c.client.Handlers.Add(girc.TOPIC, c.handleTopic) @@ -499,6 +506,9 @@ func (c *Client) handleJoin(gc *girc.Client, e girc.Event) { return } from := sourceName(e) + if strings.EqualFold(from, gc.GetNick()) { + c.rememberRoomMembership(room, true) + } c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)}) c.emitRooms() c.emitUsers(room) @@ -514,11 +524,35 @@ func (c *Client) handlePart(gc *girc.Client, e girc.Event) { if len(e.Params) > 1 { message = strings.TrimSpace(e.Last()) } + if strings.EqualFold(from, gc.GetNick()) { + c.rememberRoomMembership(room, false) + } c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)}) c.emitRooms() c.emitUsers(room) } +func (c *Client) handleKick(gc *girc.Client, e girc.Event) { + if len(e.Params) < 2 { + return + } + room := e.Params[0] + kicked := e.Params[1] + if strings.EqualFold(kicked, gc.GetNick()) { + c.rememberRoomMembership(room, false) + } + c.emit(Event{ + Type: "kick", + Target: room, + From: sourceName(e), + Command: kicked, + Text: strings.TrimSpace(e.Last()), + Timestamp: eventTime(e), + }) + c.emitRooms() + c.emitUsers(room) +} + func (c *Client) handleQuit(gc *girc.Client, e girc.Event) { from := sourceName(e) c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)}) @@ -661,7 +695,39 @@ func (c *Client) sshAuthMethods() ([]ssh.AuthMethod, error) { } func (c *Client) emitRooms() { - c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: c.Rooms(), Timestamp: time.Now().UnixMilli()}) + rooms := c.Rooms() + c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: rooms, Timestamp: time.Now().UnixMilli()}) +} + +func (c *Client) rememberRoomMembership(room string, joined bool) { + room = strings.TrimSpace(room) + if room == "" { + return + } + + c.mu.Lock() + position := -1 + changed := false + for i, existing := range c.roomNames { + if strings.EqualFold(existing, room) { + position = i + break + } + } + if joined && position == -1 { + c.roomNames = append(c.roomNames, room) + changed = true + } + if !joined && position >= 0 { + c.roomNames = append(c.roomNames[:position], c.roomNames[position+1:]...) + changed = true + } + names := append([]string{}, c.roomNames...) + c.mu.Unlock() + + if changed && c.cfg.OnRoomsChanged != nil { + c.cfg.OnRoomsChanged(names) + } } func (c *Client) emitUsers(room string) { diff --git a/internal/ircclient/ircclient_test.go b/internal/ircclient/ircclient_test.go new file mode 100644 index 0000000..d050cd3 --- /dev/null +++ b/internal/ircclient/ircclient_test.go @@ -0,0 +1,28 @@ +package ircclient + +import ( + "strings" + "testing" +) + +func TestRememberRoomMembershipTracksConfirmedChannels(t *testing.T) { + var updates []string + client := &Client{cfg: Config{ + OnRoomsChanged: func(rooms []string) { + updates = append(updates, strings.Join(rooms, ",")) + }, + }} + + client.rememberRoomMembership("#One", true) + client.rememberRoomMembership("#two", true) + client.rememberRoomMembership("#one", true) + client.rememberRoomMembership("#missing", false) + client.rememberRoomMembership("#ONE", false) + + if got, want := strings.Join(updates, "|"), "#One|#One,#two|#two"; got != want { + t.Fatalf("room updates = %q, want %q", got, want) + } + if got := strings.Join(client.roomNames, ","); got != "#two" { + t.Fatalf("remembered rooms = %q, want %q", got, "#two") + } +} diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index d31afc1..92d150f 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -1466,6 +1466,15 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin nickServPass = password } + autoJoinRooms := append([]string{}, ircConf.AutoJoinRooms...) + if m.store != nil && profile.ID != "" { + lastRooms, err := m.store.GetIRCLastRooms(profile.ID) + if err != nil { + return fmt.Errorf("loading remembered IRC rooms: %w", err) + } + autoJoinRooms = mergeIRCRooms(autoJoinRooms, lastRooms) + } + cfg := ircclient.Config{ Server: profile.Host, Port: profile.Port, @@ -1474,7 +1483,7 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin RealName: ircConf.RealName, UseTLS: ircConf.UseTLS, ServerPass: serverPass, - AutoJoinRooms: append([]string{}, ircConf.AutoJoinRooms...), + AutoJoinRooms: autoJoinRooms, NickServIdentify: ircConf.NickServIdentify, NickServAccount: ircConf.NickServAccount, NickServPassword: nickServPass, @@ -1490,6 +1499,19 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin SSHAgentAuth: ircConf.SSHAgentAuth, SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk), } + if m.store != nil && profile.ID != "" { + profileID := profile.ID + cfg.OnRoomsChanged = func(rooms []string) { + if err := m.store.SaveIRCLastRooms(profileID, rooms); err != nil { + m.emitIRCEvent(sess.Info.ID, ircclient.Event{ + Type: "system", + Target: ircclient.ServerTarget, + Text: "Could not remember open IRC rooms: " + err.Error(), + Timestamp: time.Now().UnixMilli(), + }) + } + } + } if cfg.User == "" { cfg.User = ircConf.Nick @@ -1554,6 +1576,26 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin return nil } +func mergeIRCRooms(groups ...[]string) []string { + var rooms []string + seen := make(map[string]struct{}) + for _, group := range groups { + for _, room := range group { + room = strings.TrimSpace(room) + if room == "" { + continue + } + key := strings.ToLower(room) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + rooms = append(rooms, room) + } + } + return rooms +} + func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) { m.emitEvent("session:irc:event", map[string]interface{}{ "sessionID": sessionID, diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go index 3c236de..4b2af30 100644 --- a/internal/sessions/manager_test.go +++ b/internal/sessions/manager_test.go @@ -102,6 +102,16 @@ func TestCreateTabName(t *testing.T) { } } +func TestMergeIRCRoomsPreservesConfiguredAndRememberedOrder(t *testing.T) { + got := mergeIRCRooms( + []string{" #configured ", "#Shared"}, + []string{"#shared", "#last-session", "", "#CONFIGURED"}, + ) + if joined := strings.Join(got, ","); joined != "#configured,#Shared,#last-session" { + t.Fatalf("mergeIRCRooms() = %q, want configured rooms followed by new remembered rooms", joined) + } +} + // --------------------------------------------------------------------------- // RenameSession on non-existent session // --------------------------------------------------------------------------- diff --git a/internal/storage/migrations/006_irc_channel_state.sql b/internal/storage/migrations/006_irc_channel_state.sql new file mode 100644 index 0000000..08c71f7 --- /dev/null +++ b/internal/storage/migrations/006_irc_channel_state.sql @@ -0,0 +1,15 @@ +-- Migration 006: Remember the channels open for each saved IRC profile. + +CREATE TABLE IF NOT EXISTS irc_channel_state ( + profile_id TEXT NOT NULL, + room_name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (profile_id, room_name) +); + +CREATE INDEX IF NOT EXISTS idx_irc_channel_state_profile_position + ON irc_channel_state (profile_id, position); + +INSERT INTO schema_version (version, applied_at) +VALUES (6, datetime('now')); diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 9d8016e..3a4afcf 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -33,6 +33,15 @@ func makeSSHProfile(name, host string) *models.Profile { return &p } +func makeIRCProfile(name, host string) *models.Profile { + p := models.NewProfile(models.ProtocolIRC) + p.DisplayName = name + p.Host = host + p.Username = "nexus" + p.IRC.Nick = "nexus" + return &p +} + // --------------------------------------------------------------------------- // Profile CRUD // --------------------------------------------------------------------------- @@ -104,6 +113,54 @@ func TestDeleteProfileNotFound(t *testing.T) { } } +func TestIRCLastRoomsRoundTripReplaceAndDelete(t *testing.T) { + store := setupTestStore(t) + p := makeIRCProfile("IRC Network", "irc.example.com") + if err := store.CreateProfile(p); err != nil { + t.Fatalf("CreateProfile() error = %v", err) + } + + if err := store.SaveIRCLastRooms(p.ID, []string{" #Go-Nuts ", "#nexus", "#go-nuts", ""}); err != nil { + t.Fatalf("SaveIRCLastRooms() error = %v", err) + } + rooms, err := store.GetIRCLastRooms(p.ID) + if err != nil { + t.Fatalf("GetIRCLastRooms() error = %v", err) + } + if got, want := strings.Join(rooms, ","), "#Go-Nuts,#nexus"; got != want { + t.Fatalf("GetIRCLastRooms() = %q, want %q", got, want) + } + + if err := store.SaveIRCLastRooms(p.ID, []string{"#new-room"}); err != nil { + t.Fatalf("SaveIRCLastRooms(replace) error = %v", err) + } + rooms, err = store.GetIRCLastRooms(p.ID) + if err != nil { + t.Fatalf("GetIRCLastRooms(replaced) error = %v", err) + } + if got, want := strings.Join(rooms, ","), "#new-room"; got != want { + t.Fatalf("GetIRCLastRooms(replaced) = %q, want %q", got, want) + } + + if err := store.DeleteProfile(p.ID); err != nil { + t.Fatalf("DeleteProfile() error = %v", err) + } + rooms, err = store.GetIRCLastRooms(p.ID) + if err != nil { + t.Fatalf("GetIRCLastRooms(after delete) error = %v", err) + } + if len(rooms) != 0 { + t.Fatalf("GetIRCLastRooms(after delete) = %v, want empty", rooms) + } +} + +func TestSaveIRCLastRoomsRejectsEmptyProfileID(t *testing.T) { + store := setupTestStore(t) + if err := store.SaveIRCLastRooms(" ", []string{"#room"}); err == nil { + t.Fatal("SaveIRCLastRooms() accepted an empty profile ID") + } +} + // --------------------------------------------------------------------------- // List and search // --------------------------------------------------------------------------- diff --git a/internal/storage/store.go b/internal/storage/store.go index 52824d5..bb26fb3 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -215,7 +215,16 @@ func (s *Store) UpdateProfile(p *models.Profile) error { // DeleteProfile removes a profile by ID. func (s *Store) DeleteProfile(id string) error { - res, err := s.db.Exec("DELETE FROM profiles WHERE id = ?", id) + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("storage: failed to begin profile deletion: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", id); err != nil { + return fmt.Errorf("storage: failed to delete IRC channel state: %w", err) + } + res, err := tx.Exec("DELETE FROM profiles WHERE id = ?", id) if err != nil { return fmt.Errorf("storage: failed to delete profile: %w", err) } @@ -223,6 +232,9 @@ func (s *Store) DeleteProfile(id string) error { if n == 0 { return fmt.Errorf("storage: profile %q not found", id) } + if err := tx.Commit(); err != nil { + return fmt.Errorf("storage: failed to commit profile deletion: %w", err) + } return nil } @@ -550,6 +562,80 @@ func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) { return entries, rows.Err() } +// --------------------------------------------------------------------------- +// IRC channel state +// --------------------------------------------------------------------------- + +// GetIRCLastRooms returns the channels that were open the last time the given +// saved IRC profile was connected. +func (s *Store) GetIRCLastRooms(profileID string) ([]string, error) { + rows, err := s.db.Query(`SELECT room_name FROM irc_channel_state + WHERE profile_id = ? ORDER BY position, room_name`, profileID) + if err != nil { + return nil, fmt.Errorf("storage: failed to get IRC channel state: %w", err) + } + defer rows.Close() + + rooms := make([]string, 0) + for rows.Next() { + var room string + if err := rows.Scan(&room); err != nil { + return nil, fmt.Errorf("storage: failed to scan IRC channel state: %w", err) + } + rooms = append(rooms, room) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("storage: failed to read IRC channel state: %w", err) + } + return rooms, nil +} + +// SaveIRCLastRooms atomically replaces the remembered channel set for a saved +// IRC profile. Empty names and case-insensitive duplicates are discarded. +func (s *Store) SaveIRCLastRooms(profileID string, rooms []string) error { + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return fmt.Errorf("storage: IRC channel state requires a profile ID") + } + + normalized := make([]string, 0, len(rooms)) + seen := make(map[string]struct{}, len(rooms)) + for _, room := range rooms { + room = strings.TrimSpace(room) + if room == "" { + continue + } + key := strings.ToLower(room) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, room) + } + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("storage: failed to begin IRC channel state update: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", profileID); err != nil { + return fmt.Errorf("storage: failed to clear IRC channel state: %w", err) + } + now := time.Now().Format(time.RFC3339) + for position, room := range normalized { + if _, err := tx.Exec(`INSERT INTO irc_channel_state + (profile_id, room_name, position, updated_at) VALUES (?, ?, ?, ?)`, + profileID, room, position, now); err != nil { + return fmt.Errorf("storage: failed to save IRC channel state: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("storage: failed to commit IRC channel state: %w", err) + } + return nil +} + // --------------------------------------------------------------------------- // Preferences (key-value) // ---------------------------------------------------------------------------