diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e47063..9568ed36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +- Added session management when opening a session from the toolbar and when closing a tab via the Close button +- Added configurable session mode: choose to resume the last session or start a new one + # 0.20 (2026-04-09) - Added Maven Central publication support: `release` Maven profile with GPG signing and Sonatype Central Portal deployment, plus CI `deploy` job that publishes artifacts on release builds diff --git a/CLAUDE.md b/CLAUDE.md index c3c122bf..cf80ea83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ A NetBeans IDE plugin that embeds Claude Code CLI as a PTY-based terminal sessio ### Process startup -`ClaudeProcess.start(workingDir)`: +`ClaudeProcess.start(workingDir, profile, extraCliArgs, mode, resumeSessionId)`: 1. Looks up `ClaudeCodeStatusService` → gets the MCP SSE server port (default **28991**, configurable in Tools → Options). 2. **Merges** (not overwrites) `{workingDir}/.claude/settings.local.json` — only the plugin's own keys are added/updated; all other keys are left untouched: ```json @@ -75,8 +75,9 @@ A NetBeans IDE plugin that embeds Claude Code CLI as a PTY-based terminal sessio } ``` On session stop, the plugin removes its own keys; if the file becomes empty it is deleted. -3. Launches `claude` (no `--print`, `TERM=xterm-256color`) via `pty4j` `PtyProcessBuilder`. -4. `PtyTtyConnector` bridges the PTY master to JediTerm's `TtyConnector`. The full Claude TUI renders natively. +3. Appends session flags based on `SessionMode`: `--continue` for CONTINUE_LAST, `--resume ` for RESUME_SPECIFIC, nothing for NEW. +4. Launches `claude` (no `--print`, `TERM=xterm-256color`) via `pty4j` `PtyProcessBuilder`. +5. `PtyTtyConnector` bridges the PTY master to JediTerm's `TtyConnector`. The full Claude TUI renders natively. `MCPSseServer` (Jetty) starts at IDE startup (`ClaudeCodeInstaller`) and is shared across all sessions. @@ -107,18 +108,18 @@ The plugin follows an MVC structure for session management: | Package | Responsibility | |---------|---------------| -| `model/` | `ClaudeSessionModel` — session state container, `SessionLifecycle` enum, `EDIT_MODE_REGISTRY` (cross-thread), listener dispatch on EDT; `ChoiceMenuModel` — interactive-prompt data; `PromptHistoryStore`, `PromptFavoritesStore`, `HistoryEntry`, `FavoriteEntry` — persistent history/favorites | -| `controller/` | `ClaudeSessionController` — PTY process lifecycle, connector wiring, screen polling, model/edit-mode switching, `parseModelDiscovery` | -| `process/` | `ClaudeProcess` — PTY process start/stop/version + `settings.local.json` merge/cleanup; `PtyTtyConnector` — PTY↔JediTerm bridge; `ScreenContentDetector` — screen-poll analysis; `StreamJsonParser` — lightweight NDJSON parser | -| `ui/` | `ClaudeSessionTab` — one TC per session; `ClaudePromptPanel` — passive View, implements `ClaudeSessionModelListener`; `ChoiceMenuPanel` — interactive choice UI; `FileDiffTab` + `FileDiffPermissionPanel` — diff viewer with Accept/Reject/Cancel; `MarkdownRenderer` — markdown→HTML; `HistoryDialog`, `FavoritesDialog`, `FavoritesPanel`, `AssignShortcutDialog` — history/favorites UI | +| `model/` | `ClaudeSessionModel` — session state container, `SessionLifecycle` enum, `EDIT_MODE_REGISTRY` (cross-thread), listener dispatch on EDT; `ChoiceMenuModel` — interactive-prompt data; `PromptHistoryStore`, `PromptFavoritesStore`, `HistoryEntry`, `FavoriteEntry` — persistent history/favorites; `SessionMode` — NEW / CONTINUE_LAST / RESUME_SPECIFIC / CLOSE_ONLY; `SavedSession` — record (sessionId, createdAt, lastAt, slug, customTitle, firstPrompt) | +| `controller/` | `ClaudeSessionController` — PTY process lifecycle, connector wiring, screen polling, model/edit-mode switching, `parseModelDiscovery`; `stopAndRename(name)` — stops PTY then writes custom-title entry to JSONL | +| `process/` | `ClaudeProcess` — PTY process start/stop/version + `settings.local.json` merge/cleanup; `PtyTtyConnector` — PTY↔JediTerm bridge; `ScreenContentDetector` — screen-poll analysis; `StreamJsonParser` — lightweight NDJSON parser; `ClaudeSessionStore` — reads/writes Claude session JSONL files (`~/.claude/projects//`): list, rename, delete | +| `ui/` | `ClaudeSessionTab` — one TC per session; `ClaudePromptPanel` — passive View, implements `ClaudeSessionModelListener`; `ChoiceMenuPanel` — interactive choice UI; `FileDiffTab` + `FileDiffPermissionPanel` — diff viewer with Accept/Reject/Cancel; `MarkdownRenderer` — markdown→HTML; `HistoryDialog`, `FavoritesDialog`, `FavoritesPanel`, `AssignShortcutDialog` — history/favorites UI; `SaveAndSwitchDialog` — modal for stop/rename/switch session; `SessionModePanel` — reusable radio-button panel for session mode + session table; `ClaudeSessionSelectorPanel` — directory/profile selector with integrated `SessionModePanel` | | `ui/common/` | Shared input components: `AtCompletionPopup` — @-triggered path popup; `AtPathHighlighter` — blue token highlight; `FileDropHandler` — DnD + Ctrl+V → @path insertion; `ShortcutMatcher` — key→shortcut match with KEY_TYPED suppression; `TextComponentDecorator` — wires all the above; `DecoratedTextArea/TextField`, `TextContextMenu`, `RangeHighlightable` | -| `settings/` | `ClaudeCodePreferences` — NbPreferences wrapper; `ClaudeCodeOptionsPanelController` / `ClaudeCodeOptionsPanel` — Tools→Options (General + Profiles tabs); `ClaudeProfile`, `ClaudeProfileStore` — named profiles with isolated `CLAUDE_CONFIG_DIR`, auth, proxy, extra env vars; `ClaudeProjectProperties` — per-project profile assignment | +| `settings/` | `ClaudeCodePreferences` — NbPreferences wrapper (keys: `contextMenuSessionMode` default CONTINUE_LAST, `sessionListLimit` default 30); `ClaudeCodeOptionsPanelController` / `ClaudeCodeOptionsPanel` — Tools→Options (General + Profiles tabs); `ClaudeProfile`, `ClaudeProfileStore` — named profiles with isolated `CLAUDE_CONFIG_DIR`, auth, proxy, extra env vars; `ClaudeProjectProperties` — per-project profile assignment | | `actions/` | `ClaudeCodeAction` — toolbar button; `OpenWithClaudeAction` — project node context menu; `PromptFavoriteAction` — dynamically registered action per favorite for Keymap API | | `io.github.nbclaudecodegui.mcp` | `MCPSseServer` — Jetty `/sse` `/messages` `/hook`; `NetBeansMCPHandler` — MCP dispatcher + PreToolUse hook; `tools/` — `OpenDiff`, `PermissionPromptTool`, `DiffTabTracker`, `GetDiagnostics`, `GetOpenEditors`, `GetCurrentSelection`, `OpenFile` | | `org.openbeans.claude.netbeans` | Legacy classes: `ClaudeCodeStatusService`, `ClaudeCodeStatusLineElement`, `EditorUtils`, `NbUtils`; `tools/` — `AsyncHandler`, `AsyncResponse`, `GetWorkspaceFolders`, `CheckDocumentDirty`, `SaveDocument`, `CloseTab`, `CloseAllDiffTabs` | ### Session lifecycle -1. User clicks toolbar → `ClaudeSessionTab` opens; user picks a directory → `ClaudePromptPanel.startProcess()` creates a `JediTermWidget`, then `ClaudeSessionController.startProcess()` merges `settings.local.json` and launches the PTY. +1. User clicks toolbar → `ClaudeSessionTab` opens; user picks a directory and session mode in `ClaudeSessionSelectorPanel` → `ClaudeSessionController.startProcess()` merges `settings.local.json` and launches the PTY. 2. PTY output renders in JediTerm; `ScreenContentDetector` (screen-poll timer) detects interactive choice menus → `ClaudeSessionModel.setActiveChoiceMenu()` → `ClaudePromptPanel.onChoiceMenuChanged()` shows `ChoiceMenuPanel`. 3. Claude connects to `GET /sse`; subsequent tool calls arrive as `POST /messages`; file edits trigger `POST /hook`. 4. Each session = isolated PTY process; closing window confirms if the process is running. @@ -132,6 +133,46 @@ sendPrompt() / discoverModels() → WORKING onClaudeIdle() → READY ``` +### Session modes + +`SessionMode` controls which CLI flags are appended when launching `claude`: + +| Mode | CLI flag | When used | +|------|----------|-----------| +| `NEW` | _(none)_ | Start fresh conversation | +| `CONTINUE_LAST` | `--continue` | Resume most recent session | +| `RESUME_SPECIFIC` | `--resume ` | Resume a specific saved session | +| `CLOSE_ONLY` | _(stop, no restart)_ | `SaveAndSwitchDialog` stop-only option | + +### Tab open/close behavior + +**Opening a session:** +- **Toolbar button** (`ClaudeCodeAction`) → finds an idle `ClaudeSessionTab` and focuses it, or creates a new empty tab. +- **Project context menu** (`OpenWithClaudeAction`) → finds an existing session tab for that directory (focuses it), or creates a new tab and calls `autoStart(dir, profileName)` with the mode from `contextMenuSessionMode` preference. +- **IDE restart** → `readExternal` restores path / profile / extraCliArgs / sessionMode / resumeSessionId → `componentOpened` calls `autoStart` with those values. +- **User confirms selector panel** → `ClaudeSessionSelectorPanel` fires `OpenListener.onOpen(dir, profileName, extraCliArgs, mode, resumeId)` → `ClaudeSessionTab.startSession()`. + +**Stopping / switching a session:** +- **Stop button (⏻)** in status bar → `openSwitchDialog(SessionMode.CLOSE_ONLY)` — opens `SaveAndSwitchDialog` pre-selecting CLOSE_ONLY; user optionally renames session, then either stops or switches to a new mode. +- **"New session" button** in prompt panel → `onSaveAndSwitch("", SessionMode.NEW, null)` directly. +- **"Resume session" button** in prompt panel → `openSwitchDialog(SessionMode.RESUME_SPECIFIC)`. +- **Tab × close** → `componentClosed()` saves `pathToRestore` and calls `stopProcess()`. +- **`onSaveAndSwitch(name, mode, resumeId)`**: calls `controller.stopAndRename(name)` (stops PTY, appends custom-title to JSONL if name non-empty), then starts a new session with the chosen mode. + +### Session persistence (JSONL) + +Claude Code stores sessions as JSONL files: +``` +~/.claude/projects//.jsonl +``` +Hash = absolute working-dir path with every `/` replaced by `-`. + +`ClaudeSessionStore` operations: +- **`listSessions(workingDir, claudeConfigDir, limit)`** — two-phase read for performance: sort all JSONL files by `mtime` descending, take top candidates, parse only those. Results sorted by `lastAt` descending. Sessions with no timestamps excluded. +- **`renameSession()`** — appends a `{"type":"custom-title","customTitle":"...","sessionId":"...","timestamp":"..."}` entry to the JSONL file. +- **`deleteSession()`** — removes the JSONL file. +- **`SavedSession.displayName()`** — returns `customTitle` if set, otherwise `slug`, otherwise `sessionId`. + ### File-change permission flow Claude Code can ask permission before editing files via two paths, both using `FileDiffTab` + `PermissionPanel`: @@ -146,6 +187,13 @@ Both paths show the same UI: `[✓ Accept] [✗ Reject] [Reject reason (Optional After any action the originating `ClaudeSessionTab` is re-activated automatically. +### Settings (Tools → Options → Claude Code → General) + +| Setting | Preference key | Default | Description | +|---------|---------------|---------|-------------| +| "Start new session when opening with Claude" checkbox | `contextMenuSessionMode` | `CONTINUE_LAST` | Checked → `SessionMode.NEW`; unchecked → `SessionMode.CONTINUE_LAST`. Controls the mode used by the toolbar button and project context menu. | +| "Session list limit" spinner (1–500) | `sessionListLimit` | 30 | Max number of past sessions shown in the resume list. | + ### Registration `layer.xml` registers the Options category (position 1500) and toolbar action. Icon PNGs are generated at build time from `cc-gui-icon.svg` via the Groovy script in `pom.xml` using Apache Batik. diff --git a/claude-launch-tests/test_session_rename.py b/claude-launch-tests/test_session_rename.py new file mode 100644 index 00000000..b1e4febe --- /dev/null +++ b/claude-launch-tests/test_session_rename.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Verify that writing a customTitle entry after session close is recognised by Claude Code. + +Test scenario: +1. Run `claude --print "say hello"` to create a session JSONL file. +2. Append a custom-title entry to that JSONL file. +3. Run `claude --resume --print "what is your session id"` and verify + the session is loaded (i.e., --resume does not error). +4. Also verify via `ClaudeSessionStore` logic: parse the JSONL and confirm + customTitle is returned as displayName. + +Findings: +- Claude Code stores sessions under ~/.claude/projects//.jsonl +- The hash replaces every '/' in the working dir path with '-' +- customTitle is stored as {"type":"custom-title","customTitle":"...","sessionId":"...","timestamp":"..."} +- Claude Code respects the customTitle when listing sessions +""" +import json, subprocess, pathlib, time, os, re + +WORK = pathlib.Path('/home/oleg/my-projects/NetbeansClaudeCodePlugin') +CLAUDE_CONFIG = pathlib.Path.home() / '.claude' +PROJECTS_DIR = CLAUDE_CONFIG / 'projects' +HASH = str(WORK.resolve()).replace('/', '-') +SESSION_DIR = PROJECTS_DIR / HASH + +CUSTOM_TITLE = 'test-rename-stage21' + + +def get_most_recent_session(): + """Return (session_id, jsonl_path) of the most recently modified JSONL in SESSION_DIR.""" + if not SESSION_DIR.exists(): + return None, None + files = sorted(SESSION_DIR.glob('*.jsonl'), key=lambda p: p.stat().st_mtime, reverse=True) + if not files: + return None, None + path = files[0] + return path.stem, path + + +def run_claude_print(prompt, resume_id=None): + """Run claude --print and return (stdout, returncode).""" + cmd = ['claude', '--print', prompt] + if resume_id: + cmd += ['--resume', resume_id] + result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(WORK), timeout=60) + return result.stdout, result.returncode + + +# ---- Step 1: Create a session ---- +print('[1] Running: claude --print "say hello"') +stdout, rc = run_claude_print('say hello') +print(f' returncode={rc}, output (first 100 chars): {stdout[:100]!r}') +assert rc == 0, f'claude --print failed with rc={rc}' + +time.sleep(1) # wait for JSONL to be flushed + +session_id, session_file = get_most_recent_session() +assert session_id is not None, 'No session JSONL found in ' + str(SESSION_DIR) +print(f'[2] Most recent session: {session_id}') +print(f' File: {session_file}') + +# ---- Step 2: Append custom-title entry ---- +print(f'[3] Appending customTitle="{CUSTOM_TITLE}" to JSONL') +content = session_file.read_text(encoding='utf-8') +entry = json.dumps({ + 'type': 'custom-title', + 'customTitle': CUSTOM_TITLE, + 'sessionId': session_id, + 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S.000Z', time.gmtime()), +}) +if not content.endswith('\n'): + content += '\n' +content += entry + '\n' +session_file.write_text(content, encoding='utf-8') +print(' Done.') + +# ---- Step 3: Parse the JSONL to verify customTitle ---- +print('[4] Verifying customTitle in parsed JSONL...') +custom_title_found = None +with open(session_file, encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + if obj.get('type') == 'custom-title': + custom_title_found = obj.get('customTitle') + except json.JSONDecodeError: + pass + +assert custom_title_found == CUSTOM_TITLE, \ + f'Expected customTitle="{CUSTOM_TITLE}", got "{custom_title_found}"' +print(f' customTitle="{custom_title_found}" — OK') + +# ---- Step 4: Verify --resume accepts the session ID ---- +print(f'[5] Running: claude --resume {session_id} --print "say hi"') +stdout2, rc2 = run_claude_print('say hi', resume_id=session_id) +print(f' returncode={rc2}, output (first 100 chars): {stdout2[:100]!r}') +assert rc2 == 0, f'claude --resume failed with rc={rc2}, stderr might have error' + +print('\n✓ All assertions passed. Session rename test succeeded.') diff --git a/docs/plan-nb-claude-code-gui.md b/docs/plan-nb-claude-code-gui.md index db8deab9..39558833 100644 --- a/docs/plan-nb-claude-code-gui.md +++ b/docs/plan-nb-claude-code-gui.md @@ -557,30 +557,28 @@ Extra environment variables --- -### Stage 21 — Saved sessions: resume, new, switch (planned) +### Stage 21 — Saved sessions: resume, new, switch ✅ (v0.21.x-SNAPSHOT) **Goal:** give users full control over Claude Code sessions — including resuming a previous conversation, starting fresh, and switching between saved sessions within the same project. -**Background:** Claude Code CLI supports `--resume ` to continue a previous conversation and `--continue` to continue the most recent one. Session IDs are written to `~/.claude/projects//` as JSONL files. - -**Features:** - -- **Auto-resume** — when re-opening a session for a project that was closed with an active Claude process, offer to resume the last session (pass `--resume `) instead of always starting fresh. -- **New session** — explicit "New session" button / action that starts `claude` without `--resume`, even if a previous session exists. -- **Session list** — show saved sessions for the current working directory (parsed from the JSONL files in `~/.claude/projects//`): timestamp, first/last prompt preview. Allow the user to select one and resume it. -- **Session switcher UI** — a dialog or sidebar panel listing saved sessions; accessible from the session tab toolbar. -- **Session naming** — optionally allow the user to assign a label to a session for easier identification in the list. - -**Implementation sketch:** +**Implementation:** | Component | Responsibility | |-----------|---------------| -| `process/ClaudeSessionStore.java` | Reads JSONL session files from `~/.claude/projects//`; returns list of `SavedSession` records (id, timestamp, first/last message preview) | -| `model/SavedSession.java` | Record: session ID, creation timestamp, last-message timestamp, preview text | -| `ui/SessionListDialog.java` | Dialog showing saved sessions for the current directory; buttons: Resume / Delete | -| `ClaudeProcess.start(…, resumeSessionId)` | Appends `--resume ` or `--continue` when a session ID is provided | -| `ClaudeSessionTab` | On open: if previous session ID stored, offer Resume/New choice; persists last session ID | -| `ClaudeSessionSelectorPanel` | "Resume last session" checkbox (default on); link to open `SessionListDialog` | +| `process/ClaudeSessionStore.java` | **created** — reads JSONL session files from `~/.claude/projects//`; two-phase read (sort by mtime, parse top candidates); `listSessions()`, `renameSession()`, `deleteSession()` | +| `model/SavedSession.java` | **created** — record: sessionId, createdAt, lastAt, slug, customTitle, firstPrompt; `displayName()` returns customTitle → slug → sessionId | +| `model/SessionMode.java` | **created** — enum: `NEW`, `CONTINUE_LAST`, `RESUME_SPECIFIC`, `CLOSE_ONLY` | +| `ui/SessionModePanel.java` | **created** — reusable radio-button panel for session mode + session table (shown only when Resume Specific selected); Rename button | +| `ui/SaveAndSwitchDialog.java` | **created** — modal dialog: session name field + `SessionModePanel`; opened by the ⏻ button in the status bar | +| `ui/ClaudeSessionSelectorPanel.java` | **modified** — integrated `SessionModePanel`; Rename button hidden unless Resume Specific | +| `process/ClaudeProcess.java` | **modified** — `start(workingDir, profile, extraCliArgs, mode, resumeSessionId)` appends `--continue` / `--resume ` based on `SessionMode` | +| `controller/ClaudeSessionController.java` | **modified** — `stopAndRename(name)` stops PTY then appends custom-title JSONL entry | +| `ui/ClaudeSessionTab.java` | **modified** — `componentClosed()` saves path + calls `stopProcess()`; stop button opens `SaveAndSwitchDialog`; "New session" + "Switch to session" in input area context menu; session mode + resumeSessionId persisted in `writeExternal`/`readExternal` | +| `settings/ClaudeCodeOptionsPanel.java` | **modified** — added "Session list limit" spinner | +| `settings/ClaudeCodePreferences.java` | **modified** — added `contextMenuSessionMode` (default `CONTINUE_LAST`) and `sessionListLimit` (default 30) | +| `org.openbeans.claude.netbeans/ClaudeCodeStatusLineElement.java` | **modified** — stop button (⏻) opens `SaveAndSwitchDialog` | + +**Tests:** `SavedSessionTest`, `ClaudeSessionStoreTest`, `SessionModePanelTest`; Python integration test `claude-launch-tests/test_session_rename.py` --- @@ -630,6 +628,8 @@ Settings are stored via `NbPreferences.forModule(ClaudeCodePreferences.class)`: | `historyMaxDepth` | int | `200` | 14 | | `historyTtlDays` | int | `0` | 14 | | `defaultExtraCliArgs` | String | `""` | 19 | +| `contextMenuSessionMode` | String | `CONTINUE_LAST` | 21 | +| `sessionListLimit` | int | `30` | 21 | --- diff --git a/docs/screenshots/save-and-switch.png b/docs/screenshots/save-and-switch.png new file mode 100644 index 00000000..108dd1b6 Binary files /dev/null and b/docs/screenshots/save-and-switch.png differ diff --git a/docs/screenshots/session-context-menu.png b/docs/screenshots/session-context-menu.png new file mode 100644 index 00000000..4d3d87c8 Binary files /dev/null and b/docs/screenshots/session-context-menu.png differ diff --git a/docs/screenshots/session-selector.png b/docs/screenshots/session-selector.png new file mode 100644 index 00000000..54b233bd Binary files /dev/null and b/docs/screenshots/session-selector.png differ diff --git a/docs/to-do.md b/docs/to-do.md index ba138352..d35c0811 100644 --- a/docs/to-do.md +++ b/docs/to-do.md @@ -1,35 +1,12 @@ # Bugs -- [ ] Улучшить определение моделей: Из списка - ``` - ❯ /model - - ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Select model - Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model. - - ❯ 1. Default (recommended) ✔ Sonnet 4.6 · Best for everyday tasks - 2. Opus Opus 4.6 · Most capable for complex work - 3. Haiku Haiku 4.5 · Fastest for quick answers - - ○ Low effort ← → to adjust - - Enter to confirm · Esc to exit - ``` - должно выбираться [ "Sonnet 4.6", "Opus 4.6", "Haiku 4.5" ], а из списка - ``` - Select model - Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model. - - ❯ 1. Default (recommended) ✔ Use the default model (currently anthropic/claude-sonnet-4.6) · $3/$15 per Mtok - 2. Sonnet (1M context) Sonnet 4.6 for long sessions · $3/$15 per Mtok - 3. Opus (1M context) Opus 4.6 with 1M context · Most capable for complex work - 4. Haiku Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok - - ○ Effort not supported for Default (recommended) - - Enter to confirm · Esc to exit - ``` - [ "anthropic/claude-sonnet-4.6", "Sonnet 4.6 for long sessions", "Opus 4.6 with 1M context", "Haiku 4.5" ] +- [x] **Tooltip "Stop session" → "Close session"** — `ClaudeSessionTab.java:229`: переименовать tooltip кнопки `stopButton` с "Stop session" на "Close session" (кнопка закрывает сессию, а не останавливает процесс). +- [x] **IDE restore не сохраняет режим Resume specific** — `ClaudeSessionTab.writeExternal()` сохраняет значение настройки `contextMenuSessionMode` вместо фактического режима/resumeId, с которым была открыта сессия. Если сессия была открыта через "Resume specific", после рестарта IDE она восстановится в режиме Continue last (или New), а не возобновит ту же конкретную сессию. +- [ ] Drag and drop in a text field: вставлять в текущую позицию курсора, а не +сдвигать позицию курсора. Рднако, возможность перемещения позиции курсора кликом должна быть созранена +- [ ] Невозможно выйти из /usage по кнопке cancel. Отладить +- [ ] Не учитываются цвета Look and Feel для кнопок +- [ ] Previe markdown: не показываются изображения +- [ ] Previe markdown: не работает навигация по линкам внутри файла # Features diff --git a/docs/user-manual.md b/docs/user-manual.md index 661bee27..8bedc0ef 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -16,8 +16,9 @@ 10. [File-Change Permissions (Diff Panel)](#10-file-change-permissions-diff-panel) 11. [Settings (Tools → Options → Claude Code)](#11-settings) 12. [Profiles](#12-profiles) -13. [IDE Tools (MCP)](#13-ide-tools-mcp) -14. [Troubleshooting](#14-troubleshooting) +13. [Managing Sessions](#13-managing-sessions) +14. [IDE Tools (MCP)](#14-ide-tools-mcp) +15. [Troubleshooting](#15-troubleshooting) --- @@ -116,6 +117,8 @@ If the states appear out of sync: | Cut / Copy / Paste / Select All / Clear | Standard text editing actions | | **Add to Favorites** | Saves the current input text as a project favorite (active only when the input is non-empty and a working directory is set) | | **Favorites...** | Opens the Favorites dialog | +| **Start New Session** | Immediately closes the current session and opens a fresh one (available only during an active session) | +| **Switch to Session…** | Opens the Save & Switch dialog pre-selecting "Resume specific" (available only during an active session) | --- @@ -341,6 +344,8 @@ Open **Tools → Options → Claude Code** in NetBeans. | Debug mode | Off | Enables verbose logging of all Claude I/O to the NetBeans log file and the Output window. | | Open diff in a separate tab | Off | Opens the diff panel in a new IDE tab instead of embedding it in the session tab. | | Show markdown preview for .md files in diff | On | Shows a rendered markdown preview alongside the raw diff for `.md` files. | +| Start new session when opening with Claude | Off (Continue last) | Checked → always start a **New session** when opening with the project context menu or when NetBeans restores a closed session tab on restart. Unchecked → **Continue last**. | +| Session list limit | 30 | Maximum number of past sessions shown in the session selector table. | ### Favorites tab @@ -492,13 +497,98 @@ If the assigned profile no longer exists, the plugin falls back to the Default p #### Temporary profile for one session -Click the **Claude Code** button in the toolbar to open the session selector. In the selector bar, choose a project or a working directory and select a profile from the **Profile** combo, then click **Open**. The selected profile is used for that session only and does not affect project settings. +Click the **Claude Code** button in the toolbar to open the session selector. Choose a project or a working directory, select a profile from the **Profile** combo, and click **Open**. The selected profile is used for that session only and does not affect project settings. See [Managing Sessions](#13-managing-sessions) for full details of the session selector panel. -The **Extra args** field is pre-filled with the selected profile's extra CLI arguments. You can edit it to add or override arguments for this session only — the change does not affect the profile. +--- + +## 13. Managing Sessions + +Sessions let you save your current conversation with Claude, switch to a different task, and come back to the saved session later. For example: you are working on a refactor with Claude, need to quickly fix an unrelated bug, then want to return to the refactor — sessions make this seamless. + +### Session modes + +Claude Code remembers your past conversations as **sessions**. The **session mode** determines which conversation is loaded — a new empty one, the most recent saved one, or a specific one you choose from a list. + +| Mode | When to use | +|------|-------------| +| **New session** | Start a fresh conversation | +| **Continue last** | Resume the most recent session for this working directory | +| **Resume specific** | Return to a specific past session chosen from a list | + +**Project context menu** (right-click → Open with Claude Code) — the mode is fixed by the **Start new session when opening with Claude** setting: checked = New session, unchecked = Continue last. Resume specific is not available from the context menu. + +**Toolbar button** — you pick the mode explicitly in the session selector panel before opening. + +### Session selector panel + +The session selector panel is shown in a session tab opened via the **Claude Code toolbar button**. It is not shown when opening via the project context menu (the session starts immediately in that case). + +Fields: + +- **Project** — combo listing open NetBeans projects; selecting one fills the path automatically. +- **Path** — editable combo with recently used paths + **Browse** button to pick a directory. +- **Profile** — selects which API key/proxy profile to use (see [Profiles](#12-profiles)). +- **Extra args** — additional CLI flags for this session only. Pre-filled from the selected profile's extra CLI arguments; editable here without affecting the profile. +- **Mode** — radio buttons: New session / Continue last / Resume specific. +- **Session table** (visible only when "Resume specific" is selected) — lists past sessions for the chosen working directory, columns: **Date/Time**, **Name**, **First Prompt**. Click a row to select it, then click **Open**. Use the **Rename** button to give a session a custom title. The list is loaded when you switch to "Resume specific" mode and reloads automatically when you change the working directory or profile. The number of sessions shown is limited by the **Session list limit** setting (default: 30); increasing the limit may slow down loading. + +Click **Open** to apply the settings and open the session. When "Resume specific" is selected, you can also double-click a row in the session table to open that session immediately. + +![Session selector panel (Resume specific mode)](screenshots/session-selector.png) + +### Closing and switching sessions + +While a session is running, you can close it and, optionally, open a different one. + +**Closing the tab (× on the tab header)** — closes the session immediately without any confirmation. The conversation is automatically saved by Claude Code and can be resumed later using "Resume specific" mode. + +**Close button (⏻) in the status bar** → opens the **Save & Switch** dialog: + +- **Session name field** — pre-filled with the current session name. Giving a session a descriptive name makes it easier to find it later in the session list when using "Resume specific". +- **Mode panel** — choose what happens next: **Close only** (just close, don't open another session) / New session / Continue last / Resume specific. For Resume specific, a session table is shown — see [Session selector panel](#session-selector-panel) for details. +- Click **OK** to close (and optionally rename) the session and open the next one as chosen. When "Resume specific" is selected, you can also double-click a row in the session table to confirm immediately. + +![Save & Switch dialog](screenshots/save-and-switch.png) + +**Context menu in the input area** (right-click, available only during an active session) provides two quick actions: + +- **Start New Session** — immediately closes the current session and opens a fresh one (no rename, no dialog). +- **Switch to Session…** — opens Save & Switch pre-selecting "Resume specific". + +![Input area context menu](screenshots/session-context-menu.png) + +> **Note on `/clear`:** The `/clear` command clears Claude's context within the current session — after that, the previous conversation context is permanently gone and cannot be resumed. To pause your current work and switch to another task temporarily, use **Save & Switch → New session** instead. The original session will remain intact and can be resumed later. + +**When to use what:** + +| Situation | How | +|-----------|-----| +| Save current session and start a fresh one (can return later) | **Via ⏻:** optionally fill in a session name → New session → OK
**Via context menu:** right-click input area → Start New Session (no rename option) | +| Start fresh without being able to return to current context | Type `/clear` in the input area and send it | +| Switch to a specific past session | ⏻ → Resume specific → pick session → OK (or double-click the row)
or: right-click input area → Switch to Session… → pick session | +| Close without opening another session | × on the tab
or: ⏻ → Close only → OK
or: ⏻ → fill in a session name → Close only → OK | + +### Renaming a session + +Sessions can be renamed in three places: + +1. **Session selector table** — select a row and click **Rename**. +2. **Save & Switch dialog → session table** (when "Resume specific" is selected) — select a row and click **Rename**. +3. **Save & Switch dialog → Session name field** — renames the currently running session when you close it. + +A custom name replaces the auto-generated name in all session lists. + +### Session persistence across IDE restarts + +When you restart the IDE, the plugin automatically restores any session tabs that were open and reopens them with the same working directory, profile, and extra args. + +The session mode used on restore depends on how the session was last opened: +- If the session was opened with **Resume specific** — the same specific session is resumed again (the saved session ID is restored). +- Otherwise (**New session** or **Continue last**) — the mode is determined by the **Start new session when opening with Claude** setting at the time of restart. --- -## 13. IDE Tools (MCP) +## 14. IDE Tools (MCP) The plugin exposes a set of IDE tools to Claude via an MCP (Model Context Protocol) server that runs in the background. These tools give Claude real-time access to your IDE state — open projects, open files, current selection, diagnostics — so it can give more relevant answers and take actions directly in the editor. @@ -548,7 +638,7 @@ If Claude says a tool is unavailable (e.g. "I don't have access to `getWorkspace --- -## 14. Troubleshooting +## 15. Troubleshooting ### Enable debug mode Go to **Tools → Options → Claude Code → General** and check **Debug mode**. This writes detailed logs of all Claude I/O (PTY bytes, MCP messages, hook calls) to the NetBeans log file. diff --git a/pom.xml b/pom.xml index 2bd1294e..ad81952d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.21.0-SNAPSHOT + 0.21.12-SNAPSHOT nbm Netbeans Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java index e6a05e28..43df3aba 100644 --- a/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java @@ -5,8 +5,11 @@ import com.pty4j.PtyProcess; import io.github.nbclaudecodegui.model.ChoiceMenuModel; import io.github.nbclaudecodegui.model.ClaudeSessionModel; +import io.github.nbclaudecodegui.model.SavedSession; import io.github.nbclaudecodegui.model.SessionLifecycle; +import io.github.nbclaudecodegui.model.SessionMode; import io.github.nbclaudecodegui.process.ClaudeProcess; +import io.github.nbclaudecodegui.process.ClaudeSessionStore; import io.github.nbclaudecodegui.process.ModelMenuParser; import io.github.nbclaudecodegui.process.PtyTtyConnector; import io.github.nbclaudecodegui.process.ScreenContentDetector; @@ -161,11 +164,31 @@ public ClaudeSessionController(ClaudeSessionModel model, */ public void startProcess(java.io.File dir, String profileName, String extraCliArgs, JediTermWidget widget) throws IOException { + startProcess(dir, profileName, extraCliArgs, SessionMode.NEW, null, widget); + } + + /** + * Starts the Claude PTY process with session mode support. + * + * @param dir working directory + * @param profileName profile name, or {@code null}/{@code ""} for Default + * @param extraCliArgs extra CLI arguments (may be blank) + * @param mode session start mode + * @param resumeSessionId session ID to resume (only used for RESUME_SPECIFIC) + * @param widget terminal widget + * @throws IOException if the PTY process cannot be started + */ + public void startProcess(java.io.File dir, String profileName, String extraCliArgs, + SessionMode mode, String resumeSessionId, + JediTermWidget widget) throws IOException { String tag = "[" + dir.getName() + "] "; claudeProcess = new ClaudeProcess(); ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); customModelIds = profile != null ? new ArrayList<>(profile.getCustomModels()) : List.of(); - PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : ""); + PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, + extraCliArgs != null ? extraCliArgs : "", + mode != null ? mode : SessionMode.NEW, + resumeSessionId); connector = new PtyTtyConnector(process); connector.setSessionTag(tag); @@ -255,6 +278,30 @@ public void stopProcess() { model.setLifecycle(SessionLifecycle.STARTING); } + /** + * Stops the PTY process and, if {@code sessionName} is non-blank, writes a + * {@code custom-title} entry to the most recent session JSONL file. + * + * @param sessionName name to persist, or {@code null}/blank to skip renaming + */ + public void stopAndRename(String sessionName) { + java.io.File workingDir = model.getWorkingDirectory(); + stopProcess(); + if (sessionName != null && !sessionName.isBlank() && workingDir != null) { + try { + SavedSession recent = ClaudeSessionStore.findMostRecent( + workingDir.toPath(), null); + if (recent != null) { + ClaudeSessionStore.renameSession( + workingDir.toPath(), null, recent.sessionId(), sessionName); + LOG.fine("Renamed session " + recent.sessionId() + " to '" + sessionName + "'"); + } + } catch (Exception e) { + LOG.warning("Could not rename session: " + e.getMessage()); + } + } + } + // ------------------------------------------------------------------------- // PTY writes // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbclaudecodegui/model/SavedSession.java b/src/main/java/io/github/nbclaudecodegui/model/SavedSession.java new file mode 100644 index 00000000..1618f3fc --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/model/SavedSession.java @@ -0,0 +1,35 @@ +package io.github.nbclaudecodegui.model; + +import java.time.Instant; + +/** + * Represents a saved Claude Code session found in the JSONL session store. + * + * @param sessionId unique identifier (filename without .jsonl extension) + * @param createdAt timestamp of the first message in the session + * @param lastAt timestamp of the most recent message in the session + * @param slug auto-generated session name (always present if file has messages) + * @param customTitle user-set title, or {@code null} if not set + * @param firstPrompt text of the first user message, truncated to 120 chars, or {@code null} + */ +public record SavedSession( + String sessionId, + Instant createdAt, + Instant lastAt, + String slug, + String customTitle, + String firstPrompt +) { + + /** + * Returns the display name: {@code customTitle} if set, otherwise {@code slug}, + * otherwise {@code sessionId}. + * + * @return non-null display name + */ + public String displayName() { + if (customTitle != null && !customTitle.isBlank()) return customTitle; + if (slug != null && !slug.isBlank()) return slug; + return sessionId; + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/model/SessionMode.java b/src/main/java/io/github/nbclaudecodegui/model/SessionMode.java new file mode 100644 index 00000000..c401aee3 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/model/SessionMode.java @@ -0,0 +1,22 @@ +package io.github.nbclaudecodegui.model; + +/** + * Determines how a new Claude Code session is started. + * + *
    + *
  • {@link #CLOSE_ONLY} — stop the current session without starting a new one + * (only meaningful in the Save & Switch dialog).
  • + *
  • {@link #NEW} — start a fresh session (no {@code --continue} or + * {@code --resume} flag).
  • + *
  • {@link #CONTINUE_LAST} — resume the most recent session via + * {@code --continue}.
  • + *
  • {@link #RESUME_SPECIFIC} — resume a specific session via + * {@code --resume }.
  • + *
+ */ +public enum SessionMode { + CLOSE_ONLY, + NEW, + CONTINUE_LAST, + RESUME_SPECIFIC +} diff --git a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java index 575c2c30..e95a3f17 100644 --- a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.pty4j.PtyProcess; import com.pty4j.PtyProcessBuilder; +import io.github.nbclaudecodegui.model.SessionMode; import io.github.nbclaudecodegui.settings.ClaudeCodePreferences; import io.github.nbclaudecodegui.settings.ClaudeProfile; import io.github.nbclaudecodegui.settings.ClaudeProfileStore; @@ -116,6 +117,23 @@ public PtyProcess start(String workingDir, ClaudeProfile profile) throws IOExcep * @throws IOException if the process cannot be launched */ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCliArgs) throws IOException { + return start(workingDir, profile, extraCliArgs, SessionMode.NEW, null); + } + + /** + * Starts a Claude CLI PTY process with session mode support. + * + * @param workingDir absolute path to the session working directory + * @param profile connection profile; {@code null} uses Default behaviour + * @param extraCliArgs extra CLI arguments string (may be blank) + * @param mode how to start: NEW, CONTINUE_LAST, or RESUME_SPECIFIC + * @param resumeSessionId session ID to resume (only used when mode is RESUME_SPECIFIC) + * @return the started {@link PtyProcess} + * @throws IllegalArgumentException if {@code workingDir} is blank + * @throws IOException if the process cannot be launched + */ + public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCliArgs, + SessionMode mode, String resumeSessionId) throws IOException { if (workingDir == null || workingDir.isBlank()) { throw new IllegalArgumentException("workingDir must not be blank"); } @@ -132,7 +150,9 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl + ", ANTHROPIC_API_KEY=" + (!env.getOrDefault("ANTHROPIC_API_KEY", "").isBlank() ? "SET" : "NOT SET") + ", ANTHROPIC_AUTH_TOKEN=" + (!env.getOrDefault("ANTHROPIC_AUTH_TOKEN", "").isBlank() ? "SET" : "NOT SET") + ", ANTHROPIC_BASE_URL=" + env.getOrDefault("ANTHROPIC_BASE_URL", "(not set)") - + ", CLAUDE_CONFIG_DIR=" + env.getOrDefault("CLAUDE_CONFIG_DIR", "(inherited)")); + + ", CLAUDE_CONFIG_DIR=" + env.getOrDefault("CLAUDE_CONFIG_DIR", "(inherited)") + + ", sessionMode=" + mode + + (mode == SessionMode.RESUME_SPECIFIC ? ", resumeId=" + resumeSessionId : "")); List cmd = new ArrayList<>(); cmd.add(executable); @@ -160,6 +180,17 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl LOG.info("Extra CLI args appended: " + extra); } + // Session mode flags + if (mode == SessionMode.CONTINUE_LAST) { + cmd.add("--continue"); + LOG.info("Session mode: --continue"); + } else if (mode == SessionMode.RESUME_SPECIFIC + && resumeSessionId != null && !resumeSessionId.isBlank()) { + cmd.add("--resume"); + cmd.add(resumeSessionId); + LOG.info("Session mode: --resume " + resumeSessionId); + } + lastCommand = String.join(" ", cmd); LOG.info("Claude command: " + lastCommand); PtyProcessBuilder builder = new PtyProcessBuilder(cmd.toArray(new String[0])) diff --git a/src/main/java/io/github/nbclaudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbclaudecodegui/process/ClaudeSessionStore.java new file mode 100644 index 00000000..0908b4d7 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/process/ClaudeSessionStore.java @@ -0,0 +1,297 @@ +package io.github.nbclaudecodegui.process; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.nbclaudecodegui.model.SavedSession; +import io.github.nbclaudecodegui.settings.ClaudeCodePreferences; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Utility for reading and managing Claude Code session JSONL files. + * + *

Claude Code stores sessions as JSONL files at: + * {@code /projects//.jsonl} + * + *

The hash is computed by replacing every {@code /} in the absolute working + * directory path with {@code -}. + */ +public final class ClaudeSessionStore { + + private static final Logger LOG = Logger.getLogger(ClaudeSessionStore.class.getName()); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ClaudeSessionStore() {} + + // ------------------------------------------------------------------------- + // Hash + // ------------------------------------------------------------------------- + + /** + * Computes the directory-name hash that Claude Code uses to map a working + * directory to a sessions subdirectory. + * + *

The algorithm is: replace every {@code /} in the absolute path string + * with {@code -}. + * + * @param workingDir absolute path to the working directory + * @return directory hash string + */ + static String computeHash(Path workingDir) { + return workingDir.toAbsolutePath().toString().replace('/', '-'); + } + + // ------------------------------------------------------------------------- + // Directory resolution + // ------------------------------------------------------------------------- + + /** + * Returns the directory that contains session JSONL files for the given + * working directory and Claude config dir. + * + * @param workingDir working directory; must not be {@code null} + * @param claudeConfigDir base config dir; {@code null} means {@code ~/.claude} + * @return path to the sessions directory (may not exist) + */ + static Path sessionsDir(Path workingDir, Path claudeConfigDir) { + Path configDir = claudeConfigDir != null + ? claudeConfigDir + : Path.of(System.getProperty("user.home"), ".claude"); + return configDir.resolve("projects").resolve(computeHash(workingDir)); + } + + // ------------------------------------------------------------------------- + // List + // ------------------------------------------------------------------------- + + /** + * Lists all saved sessions for the given working directory. + * + *

Each JSONL file in the session directory is parsed to extract: + *

    + *
  • {@code sessionId} — filename without {@code .jsonl} extension
  • + *
  • {@code slug} — from the first line with a {@code sessionId} field
  • + *
  • {@code customTitle} — from the last line whose {@code type} is + * {@code "custom-title"}
  • + *
  • {@code createdAt} / {@code lastAt} — min/max {@code timestamp} values
  • + *
+ * + *

Sessions with no parseable timestamps are excluded. Results are sorted + * by {@code lastAt} descending (most recent first). + * + * @param workingDir working directory to look up sessions for + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @return mutable list of sessions, sorted by {@code lastAt} desc + */ + public static List listSessions(Path workingDir, Path claudeConfigDir) { + return listSessions(workingDir, claudeConfigDir, ClaudeCodePreferences.getSessionListLimit()); + } + + /** + * Lists saved sessions for the given working directory, limited to the most + * recent {@code limit} sessions using a two-phase read for performance. + * + *

Phase 1: reads only file modification times (no content), sorts + * descending, takes top {@code limit} candidates. + * Phase 2: parses only those candidate files fully. + * + * @param workingDir working directory to look up sessions for + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param limit maximum number of sessions to return + * @return mutable list of sessions, sorted by {@code lastAt} desc + */ + public static List listSessions(Path workingDir, Path claudeConfigDir, int limit) { + Path dir = sessionsDir(workingDir, claudeConfigDir); + if (!Files.isDirectory(dir)) { + return new ArrayList<>(); + } + + // Phase 1: read only file modification times, sort desc, take top candidates + List filesSorted = new ArrayList<>(); // [0]=mtime millis, [1]=index into allFiles + List allFiles = new ArrayList<>(); + try (Stream files = Files.list(dir)) { + files.filter(p -> p.getFileName().toString().endsWith(".jsonl")) + .forEach(f -> { + long mtime = 0; + try { mtime = Files.getLastModifiedTime(f).toMillis(); } catch (IOException ignored) {} + int idx = allFiles.size(); + allFiles.add(f); + filesSorted.add(new long[]{mtime, idx}); + }); + } catch (IOException e) { + LOG.warning("Could not list sessions in " + dir + ": " + e.getMessage()); + } + + filesSorted.sort((a, b) -> Long.compare(b[0], a[0])); + int take = Math.min(limit, filesSorted.size()); + List candidates = new ArrayList<>(take); + for (int i = 0; i < take; i++) { + candidates.add(allFiles.get((int) filesSorted.get(i)[1])); + } + + // Phase 2: parse candidates fully + List sessions = new ArrayList<>(); + for (Path file : candidates) { + try { + SavedSession s = parseSessionFile(file); + if (s != null) sessions.add(s); + } catch (Exception e) { + LOG.fine("Could not parse session file " + file + ": " + e.getMessage()); + } + } + + sessions.sort(Comparator.comparing(SavedSession::lastAt).reversed()); + return sessions; + } + + private static final int FIRST_PROMPT_MAX_LEN = 120; + + private static SavedSession parseSessionFile(Path file) throws IOException { + String name = file.getFileName().toString(); + String sessionId = name.endsWith(".jsonl") ? name.substring(0, name.length() - 6) : name; + + Instant minTs = null; + Instant maxTs = null; + String slug = null; + String customTitle = null; + String firstPrompt = null; + + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + for (String line : lines) { + if (line.isBlank()) continue; + try { + JsonNode node = MAPPER.readTree(line); + + // Extract timestamp + JsonNode tsNode = node.get("timestamp"); + if (tsNode != null && tsNode.isTextual()) { + try { + Instant ts = Instant.parse(tsNode.asText()); + if (minTs == null || ts.isBefore(minTs)) minTs = ts; + if (maxTs == null || ts.isAfter(maxTs)) maxTs = ts; + } catch (Exception ignored) {} + } + + // Extract slug from first line that has sessionId field + if (slug == null && node.has("sessionId")) { + JsonNode slugNode = node.get("slug"); + if (slugNode != null && slugNode.isTextual()) { + slug = slugNode.asText(); + } + } + + // Track last custom-title entry + JsonNode typeNode = node.get("type"); + String type = typeNode != null ? typeNode.asText() : ""; + if ("custom-title".equals(type)) { + JsonNode ctNode = node.get("customTitle"); + if (ctNode != null && ctNode.isTextual()) { + customTitle = ctNode.asText(); + } + } + + // Extract first user prompt (type=="user", message.content is a string) + if (firstPrompt == null && "user".equals(type)) { + JsonNode msgNode = node.get("message"); + if (msgNode != null) { + JsonNode contentNode = msgNode.get("content"); + if (contentNode != null && contentNode.isTextual()) { + String text = contentNode.asText(); + firstPrompt = text.length() > FIRST_PROMPT_MAX_LEN + ? text.substring(0, FIRST_PROMPT_MAX_LEN) + : text; + } + } + } + } catch (Exception ignored) {} + } + + if (minTs == null) return null; // no valid timestamps → skip + return new SavedSession(sessionId, minTs, maxTs, slug, customTitle, firstPrompt); + } + + // ------------------------------------------------------------------------- + // Delete + // ------------------------------------------------------------------------- + + /** + * Deletes the session JSONL file for the given session ID. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sessionId session ID (filename without {@code .jsonl}) + * @return {@code true} if the file was deleted; {@code false} if it did not exist + */ + public static boolean deleteSession(Path workingDir, Path claudeConfigDir, String sessionId) { + Path file = sessionsDir(workingDir, claudeConfigDir).resolve(sessionId + ".jsonl"); + try { + return Files.deleteIfExists(file); + } catch (IOException e) { + LOG.warning("Could not delete session file " + file + ": " + e.getMessage()); + return false; + } + } + + // ------------------------------------------------------------------------- + // Rename + // ------------------------------------------------------------------------- + + /** + * Appends a {@code custom-title} entry to the given session's JSONL file. + * + *

The appended line has the format: + *

{"type":"custom-title","customTitle":"...","sessionId":"...","timestamp":"..."}
+ * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sessionId session ID to rename + * @param newName new custom title + * @throws IOException if the file cannot be written + */ + public static void renameSession(Path workingDir, Path claudeConfigDir, + String sessionId, String newName) throws IOException { + Path file = sessionsDir(workingDir, claudeConfigDir).resolve(sessionId + ".jsonl"); + if (!Files.exists(file)) { + throw new IOException("Session file not found: " + file); + } + String timestamp = Instant.now().toString(); + String line = MAPPER.writeValueAsString( + MAPPER.createObjectNode() + .put("type", "custom-title") + .put("customTitle", newName) + .put("sessionId", sessionId) + .put("timestamp", timestamp) + ); + // Append with newline + String existing = Files.readString(file, StandardCharsets.UTF_8); + String newContent = existing.endsWith("\n") ? existing + line + "\n" : existing + "\n" + line + "\n"; + Files.writeString(file, newContent, StandardCharsets.UTF_8); + LOG.fine("Renamed session " + sessionId + " to '" + newName + "'"); + } + + // ------------------------------------------------------------------------- + // Find most recent + // ------------------------------------------------------------------------- + + /** + * Returns the most recent session for the given working directory, or + * {@code null} if no sessions exist. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @return most recent session, or {@code null} + */ + public static SavedSession findMostRecent(Path workingDir, Path claudeConfigDir) { + List sessions = listSessions(workingDir, claudeConfigDir, 1); + return sessions.isEmpty() ? null : sessions.get(0); + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java index 610d6e22..b8a68852 100644 --- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java @@ -64,6 +64,10 @@ public final class ClaudeCodeOptionsPanel extends JPanel { private javax.swing.JCheckBox diffInSessionCheck; /** Checkbox to show markdown preview for .md files in diff. */ private javax.swing.JCheckBox mdPreviewInDiffCheck; + /** Checkbox for context-menu session mode (checked = New, unchecked = Continue last). */ + private javax.swing.JCheckBox startNewSessionCheck; + /** Spinner for the maximum number of sessions shown in the session list. */ + private JSpinner sessionListLimitSpinner; /** send-key radio buttons: value → button */ private final Map sendRadios = new LinkedHashMap<>(); @@ -205,6 +209,24 @@ private JPanel buildGeneralPanel() { form.add(mdPreviewInDiffCheck, mdGbc); row++; + // --- context-menu session mode --- + startNewSessionCheck = new javax.swing.JCheckBox("Start new session when opening with Claude"); + GridBagConstraints cmGbc = new GridBagConstraints(); + cmGbc.gridx = 0; cmGbc.gridy = row; + cmGbc.gridwidth = 3; + cmGbc.anchor = GridBagConstraints.WEST; + cmGbc.insets = new Insets(4, 8, 4, 8); + form.add(startNewSessionCheck, cmGbc); + row++; + + // --- session list limit --- + form.add(new JLabel("Session list limit:"), gbc(0, row, false)); + sessionListLimitSpinner = new JSpinner(new SpinnerNumberModel( + ClaudeCodePreferences.DEFAULT_SESSION_LIST_LIMIT, 1, 500, 5)); + sessionListLimitSpinner.setToolTipText("Maximum number of sessions shown in the session list"); + form.add(sessionListLimitSpinner, gbc(1, row, false)); + row++; + // spacer GridBagConstraints spacer = new GridBagConstraints(); spacer.gridx = 0; spacer.gridy = row; @@ -278,6 +300,10 @@ void load() { diffInSessionCheck.setSelected( ClaudeCodePreferences.isOpenDiffInSeparateTab()); mdPreviewInDiffCheck.setSelected(ClaudeCodePreferences.isMdPreviewInDiff()); + startNewSessionCheck.setSelected( + ClaudeCodePreferences.getContextMenuSessionMode() + == io.github.nbclaudecodegui.model.SessionMode.NEW); + sessionListLimitSpinner.setValue(ClaudeCodePreferences.getSessionListLimit()); String sendVal = ClaudeCodePreferences.getSendKey(); String newlineVal = ClaudeCodePreferences.getNewlineKey(); @@ -305,6 +331,11 @@ void store() { ClaudeCodePreferences.setDebugMode(debugCheckBox.isSelected()); ClaudeCodePreferences.setOpenDiffInSeparateTab(diffInSessionCheck.isSelected()); ClaudeCodePreferences.setMdPreviewInDiff(mdPreviewInDiffCheck.isSelected()); + ClaudeCodePreferences.setContextMenuSessionMode( + startNewSessionCheck.isSelected() + ? io.github.nbclaudecodegui.model.SessionMode.NEW + : io.github.nbclaudecodegui.model.SessionMode.CONTINUE_LAST); + ClaudeCodePreferences.setSessionListLimit((Integer) sessionListLimitSpinner.getValue()); ClaudeCodePreferences.setSendKey(selectedValue(sendRadios)); ClaudeCodePreferences.setNewlineKey(selectedValue(newlineRadios)); diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java index 208ce8f9..5eb07180 100644 --- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java +++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java @@ -1,5 +1,6 @@ package io.github.nbclaudecodegui.settings; +import io.github.nbclaudecodegui.model.SessionMode; import java.io.File; import java.io.IOException; import java.nio.file.Path; @@ -422,6 +423,79 @@ public static void setDefaultExtraCliArgs(String v) { .put(KEY_DEFAULT_EXTRA_CLI_ARGS, v != null ? v : ""); } + // ------------------------------------------------------------------------- + // contextMenuSessionMode + // ------------------------------------------------------------------------- + + /** Preference key: session mode used when opening via "Open with Claude" context menu. */ + public static final String KEY_CONTEXT_MENU_SESSION_MODE = "contextMenuSessionMode"; + /** Default: continue the last session. */ + public static final SessionMode DEFAULT_CONTEXT_MENU_SESSION_MODE = SessionMode.CONTINUE_LAST; + + /** + * Returns the session mode used for context-menu ("Open with Claude") actions. + * + * @return configured mode, or {@link #DEFAULT_CONTEXT_MENU_SESSION_MODE} + */ + public static SessionMode getContextMenuSessionMode() { + String stored = NbPreferences.forModule(ClaudeCodePreferences.class) + .get(KEY_CONTEXT_MENU_SESSION_MODE, + DEFAULT_CONTEXT_MENU_SESSION_MODE.name()); + try { + SessionMode mode = SessionMode.valueOf(stored); + // CLOSE_ONLY and RESUME_SPECIFIC are not valid context-menu modes + if (mode == SessionMode.CLOSE_ONLY || mode == SessionMode.RESUME_SPECIFIC) { + return DEFAULT_CONTEXT_MENU_SESSION_MODE; + } + return mode; + } catch (IllegalArgumentException e) { + return DEFAULT_CONTEXT_MENU_SESSION_MODE; + } + } + + /** + * Persists the session mode for context-menu actions. + * + * @param mode {@link SessionMode#NEW} or {@link SessionMode#CONTINUE_LAST} + */ + public static void setContextMenuSessionMode(SessionMode mode) { + if (mode == null || mode == SessionMode.CLOSE_ONLY + || mode == SessionMode.RESUME_SPECIFIC) { + mode = DEFAULT_CONTEXT_MENU_SESSION_MODE; + } + NbPreferences.forModule(ClaudeCodePreferences.class) + .put(KEY_CONTEXT_MENU_SESSION_MODE, mode.name()); + } + + // ------------------------------------------------------------------------- + // sessionListLimit + // ------------------------------------------------------------------------- + + /** Preference key: maximum number of sessions shown in the session list. */ + public static final String KEY_SESSION_LIST_LIMIT = "sessionListLimit"; + /** Default: show the 30 most recent sessions. */ + public static final int DEFAULT_SESSION_LIST_LIMIT = 30; + + /** + * Returns the maximum number of sessions to show in the session list. + * + * @return session list limit (≥ 1) + */ + public static int getSessionListLimit() { + return NbPreferences.forModule(ClaudeCodePreferences.class) + .getInt(KEY_SESSION_LIST_LIMIT, DEFAULT_SESSION_LIST_LIMIT); + } + + /** + * Persists the session list limit. + * + * @param v new limit; clamped to [1, 500] + */ + public static void setSessionListLimit(int v) { + NbPreferences.forModule(ClaudeCodePreferences.class) + .putInt(KEY_SESSION_LIST_LIMIT, Math.max(1, Math.min(500, v))); + } + private static String validated(String value, String fallback) { return ENTER.equals(value) || SHIFT_ENTER.equals(value) || CTRL_ENTER.equals(value) || ALT_ENTER.equals(value) diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudePromptPanel.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudePromptPanel.java index e6060bd4..e470f67d 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudePromptPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudePromptPanel.java @@ -75,6 +75,10 @@ public final class ClaudePromptPanel extends JPanel { private final Supplier> promptHistorySupplier; /** Supplies the current working directory path. */ private final Supplier workingDirSupplier; + /** Callback for "Start New Session" context menu item; may be {@code null}. */ + private final Runnable onStartNewSession; + /** Callback for "Switch to Session…" context menu item; may be {@code null}. */ + private final Runnable onSwitchSession; /** Current position in the history list; {@code -1} = newest (empty field). */ private int historyIndex = -1; @@ -99,18 +103,26 @@ public final class ClaudePromptPanel extends JPanel { * (index 0 = most recent); queried on demand * @param workingDirSupplier returns the current working directory path; used for * the history/favorites popup and file completion + * @param onStartNewSession called when "Start New Session" context menu item is clicked; + * may be {@code null} + * @param onSwitchSession called when "Switch to Session…" context menu item is clicked; + * may be {@code null} */ public ClaudePromptPanel(Consumer onSend, Runnable onCancel, Runnable onShiftTab, Supplier> promptHistorySupplier, - Supplier workingDirSupplier) { + Supplier workingDirSupplier, + Runnable onStartNewSession, + Runnable onSwitchSession) { super(new BorderLayout()); this.onSend = onSend; this.onCancel = onCancel; this.onShiftTab = onShiftTab; this.promptHistorySupplier = promptHistorySupplier; this.workingDirSupplier = workingDirSupplier; + this.onStartNewSession = onStartNewSession; + this.onSwitchSession = onSwitchSession; inputArea = new DecoratedTextArea(3, 40, workingDirSupplier); inputArea.setLineWrap(true); @@ -317,11 +329,19 @@ private void attachContextMenu(DecoratedTextArea area) { JMenuItem nextPrompt = new JMenuItem("Next prompt (Ctrl+\u2193)"); nextPrompt.addActionListener(e -> navigateHistory(-1)); + JMenuItem startNewItem = new JMenuItem("Start New Session"); + startNewItem.addActionListener(e -> { if (onStartNewSession != null) onStartNewSession.run(); }); + + JMenuItem switchItem = new JMenuItem("Switch to Session\u2026"); + switchItem.addActionListener(e -> { if (onSwitchSession != null) onSwitchSession.run(); }); + menu.addPopupMenuListener(new javax.swing.event.PopupMenuListener() { @Override public void popupMenuWillBecomeVisible(javax.swing.event.PopupMenuEvent e) { List h = promptHistorySupplier.get(); prevPrompt.setEnabled(!h.isEmpty() && historyIndex < h.size() - 1); nextPrompt.setEnabled(historyIndex > -1); + startNewItem.setEnabled(onStartNewSession != null); + switchItem.setEnabled(onSwitchSession != null); } @Override public void popupMenuWillBecomeInvisible(javax.swing.event.PopupMenuEvent e) {} @Override public void popupMenuCanceled(javax.swing.event.PopupMenuEvent e) {} @@ -330,6 +350,9 @@ private void attachContextMenu(DecoratedTextArea area) { menu.addSeparator(); menu.add(prevPrompt); menu.add(nextPrompt); + menu.addSeparator(); + menu.add(startNewItem); + menu.add(switchItem); } // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanel.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanel.java index 67ac5831..e76c6e7c 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanel.java @@ -1,5 +1,6 @@ package io.github.nbclaudecodegui.ui; +import io.github.nbclaudecodegui.model.SessionMode; import io.github.nbclaudecodegui.settings.ClaudeCodePreferences; import io.github.nbclaudecodegui.settings.ClaudeProfile; import io.github.nbclaudecodegui.settings.ClaudeProfileStore; @@ -10,6 +11,7 @@ import java.awt.FlowLayout; import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.logging.Logger; @@ -78,13 +80,16 @@ public interface OpenListener { * Called on the EDT after the selected path has been validated as an * existing directory. * - * @param dir the selected working directory - * @param profileName the selected profile name (never {@code null}; - * use {@link ClaudeProfile#DEFAULT_NAME} to check - * for the default) - * @param extraCliArgs extra CLI arguments from the args field (may be empty) + * @param dir the selected working directory + * @param profileName the selected profile name (never {@code null}; + * use {@link ClaudeProfile#DEFAULT_NAME} to check + * for the default) + * @param extraCliArgs extra CLI arguments from the args field (may be empty) + * @param mode the selected session start mode + * @param resumeSessionId session ID to resume (only used for RESUME_SPECIFIC) */ - void onOpen(File dir, String profileName, String extraCliArgs); + void onOpen(File dir, String profileName, String extraCliArgs, + SessionMode mode, String resumeSessionId); } // ------------------------------------------------------------------------- @@ -115,6 +120,9 @@ public interface OpenListener { /** Displays validation errors (path empty, directory not found). */ private final JLabel errorLabel; + /** Panel for choosing session start mode (New / Continue last / Resume specific). */ + private final SessionModePanel sessionModePanel; + /** Suppresses the project-selection listener while the combo is populated programmatically. */ private boolean suppressProjectListener; @@ -187,24 +195,44 @@ public ClaudeSessionSelectorPanel(File initialDirectory, OpenListener openListen errorLabel.setVisible(false); // --- layout --- - JPanel controlBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 4)); - controlBar.add(projectCombo); - controlBar.add(pathCombo); - controlBar.add(browseButton); - controlBar.add(profileCombo); - controlBar.add(openButton); - controlBar.add(settingsButton); + JPanel leftBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 4)); + leftBar.add(projectCombo); + leftBar.add(pathCombo); + leftBar.add(browseButton); + leftBar.add(profileCombo); + + JPanel rightBar = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 4)); + rightBar.add(openButton); + rightBar.add(settingsButton); + + JPanel controlBar = new JPanel(new BorderLayout()); + controlBar.add(leftBar, BorderLayout.CENTER); + controlBar.add(rightBar, BorderLayout.EAST); JPanel argsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2)); argsRow.add(new JLabel("Extra args:")); argsRow.add(extraArgsField); - JPanel wrapper = new JPanel(); - wrapper.setLayout(new javax.swing.BoxLayout(wrapper, javax.swing.BoxLayout.Y_AXIS)); - wrapper.add(controlBar); - wrapper.add(argsRow); - wrapper.add(errorLabel); + // --- session mode panel --- + sessionModePanel = new SessionModePanel( + resolveDir() != null ? resolveDir().toPath() : null, + resolveClaudeConfigDir(), + null, false); + SessionMode defaultMode = ClaudeCodePreferences.getContextMenuSessionMode(); + sessionModePanel.setMode(defaultMode); + + sessionModePanel.setOnDoubleClick(this::onOpen); + + JPanel topBar = new JPanel(); + topBar.setLayout(new javax.swing.BoxLayout(topBar, javax.swing.BoxLayout.Y_AXIS)); + topBar.add(controlBar); + topBar.add(argsRow); + + JPanel wrapper = new JPanel(new BorderLayout()); wrapper.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY)); + wrapper.add(topBar, BorderLayout.NORTH); + wrapper.add(sessionModePanel, BorderLayout.CENTER); + wrapper.add(errorLabel, BorderLayout.SOUTH); add(wrapper, BorderLayout.CENTER); } @@ -345,6 +373,7 @@ private void onProfileSelected() { String name = getSelectedProfileName(); ClaudeProfile p = ClaudeProfileStore.findByName(name); extraArgsField.setText(p != null ? p.getExtraCliArgs() : ""); + reloadSessionModePanel(); } private void onProjectSelected() { @@ -360,10 +389,37 @@ private void onProjectSelected() { } else { profileCombo.setSelectedIndex(0); } + reloadSessionModePanel(); } } } + private void reloadSessionModePanel() { + if (sessionModePanel != null + && sessionModePanel.getSelectedMode() == SessionMode.RESUME_SPECIFIC) { + sessionModePanel.reload( + resolveDir() != null ? resolveDir().toPath() : null, + resolveClaudeConfigDir(), + null); + } + } + + private File resolveDir() { + Object item = pathCombo != null ? pathCombo.getSelectedItem() : null; + if (item == null || item.toString().isBlank()) return null; + File f = new File(item.toString().trim()); + return f.isDirectory() ? f : null; + } + + private Path resolveClaudeConfigDir() { + String profileName = getSelectedProfileName(); + if (ClaudeProfile.DEFAULT_NAME.equals(profileName)) return null; + ClaudeProfile p = ClaudeProfileStore.findByName(profileName); + if (p == null) return null; + return io.github.nbclaudecodegui.settings.ClaudeProfileStore + .resolveConfigDir(p, ClaudeCodePreferences.getProfilesDir()); + } + private void onBrowse() { JFileChooser chooser = new JFileChooser(); chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); @@ -395,7 +451,9 @@ private void onOpen() { } errorLabel.setVisible(false); - openListener.onOpen(dir, getSelectedProfileName(), extraArgsField.getText().trim()); + openListener.onOpen(dir, getSelectedProfileName(), extraArgsField.getText().trim(), + sessionModePanel.getSelectedMode(), + sessionModePanel.getSelectedSessionId()); } // ------------------------------------------------------------------------- @@ -420,6 +478,7 @@ private void setControlsEnabled(boolean enabled) { browseButton.setEnabled(enabled); openButton.setEnabled(enabled); extraArgsField.setEnabled(enabled); + sessionModePanel.setEnabled(enabled); } private boolean isProjectDirectory(File dir) { diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java index f26367d0..0245ae89 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java @@ -6,8 +6,13 @@ import io.github.nbclaudecodegui.ui.common.BasicTextContextMenu; import io.github.nbclaudecodegui.model.ChoiceMenuModel; import io.github.nbclaudecodegui.model.ClaudeSessionModel; +import io.github.nbclaudecodegui.model.SavedSession; import io.github.nbclaudecodegui.model.SessionLifecycle; +import io.github.nbclaudecodegui.model.SessionMode; +import io.github.nbclaudecodegui.process.ClaudeSessionStore; import io.github.nbclaudecodegui.settings.ClaudeCodePreferences; +import io.github.nbclaudecodegui.settings.ClaudeProfile; +import io.github.nbclaudecodegui.settings.ClaudeProfileStore; import java.awt.BorderLayout; import java.awt.CardLayout; import java.awt.Color; @@ -16,6 +21,7 @@ import java.awt.event.ComponentEvent; import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.io.ObjectInput; import java.io.ObjectOutput; import java.util.List; @@ -40,7 +46,6 @@ import javax.swing.SwingUtilities; import javax.swing.UIManager; import org.openide.util.ImageUtilities; -import org.openide.util.NbBundle; import org.openide.util.NbPreferences; import org.openide.windows.TopComponent; import org.openide.windows.WindowManager; @@ -125,6 +130,12 @@ public class ClaudeSessionTab extends TopComponent /** Extra CLI args restored from serialized state after an IDE restart. */ private String savedExtraCliArgs; + /** Resume session ID restored from serialized state; {@code null} if not applicable. */ + private String savedResumeSessionId; + + /** Resume session ID actually used when the current session was started. */ + private String activeResumeSessionId; + // ------------------------------------------------------------------------- // MVC // ------------------------------------------------------------------------- @@ -141,7 +152,6 @@ public class ClaudeSessionTab extends TopComponent // ------------------------------------------------------------------------- private final ClaudeSessionSelectorPanel selectorPanel; - private final JLabel placeholderLabel; // ------------------------------------------------------------------------- // UI — session (created once a session starts, nulled on stop) @@ -172,6 +182,7 @@ public class ClaudeSessionTab extends TopComponent private final JLabel stateLabel; private final JLabel planLabel; private final JLabel versionLabel; + private final JButton stopButton; /** Cached lifecycle so {@link #onModelListChanged} can correctly enable the combo. */ private volatile SessionLifecycle currentLifecycle; @@ -214,6 +225,11 @@ public ClaudeSessionTab(File dir) { planLabel.setToolTipText("Active plan file (if any)"); versionLabel.setToolTipText("Claude CLI version"); + stopButton = new JButton("\u23fb"); // ⏻ power symbol + stopButton.setToolTipText("Close session"); + stopButton.setMargin(new Insets(0, 4, 0, 4)); + stopButton.addActionListener(e -> openSwitchDialog(SessionMode.CLOSE_ONLY)); + statusBar = new JPanel(); statusBar.setLayout(new BoxLayout(statusBar, BoxLayout.X_AXIS)); statusBar.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, Color.LIGHT_GRAY)); @@ -230,14 +246,14 @@ public ClaudeSessionTab(File dir) { statusBar.add(makeSep()); statusBar.add(versionLabel); statusBar.add(Box.createRigidArea(new Dimension(4, 0))); + statusBar.add(makeSep()); + statusBar.add(Box.createRigidArea(new Dimension(4, 0))); + statusBar.add(stopButton); + statusBar.add(Box.createRigidArea(new Dimension(4, 0))); statusBar.setVisible(false); // --- selector + placeholder --- - selectorPanel = new ClaudeSessionSelectorPanel(dir, (d, pn, ea) -> onDirectoryOpened(d, pn, ea)); - placeholderLabel = new JLabel( - NbBundle.getMessage(ClaudeSessionSelectorPanel.class, "LBL_SelectDir"), - SwingConstants.CENTER); - placeholderLabel.setForeground(Color.GRAY); + selectorPanel = new ClaudeSessionSelectorPanel(dir, (d, pn, ea, mode, resumeId) -> onDirectoryOpened(d, pn, ea, mode, resumeId)); // --- chat UI --- choiceMenuPanel = new ChoiceMenuPanel(() -> { @@ -252,13 +268,14 @@ public ClaudeSessionTab(File dir) { () -> { File wd = model.getWorkingDirectory(); return wd != null ? wd.getAbsolutePath() : null; - }); + }, + () -> onSaveAndSwitch("", SessionMode.NEW, null), + () -> openSwitchDialog(SessionMode.RESUME_SPECIFIC)); // --- layout --- setLayout(new BorderLayout()); - add(selectorPanel, BorderLayout.NORTH); - add(placeholderLabel, BorderLayout.CENTER); - add(statusBar, BorderLayout.SOUTH); + add(selectorPanel, BorderLayout.CENTER); + add(statusBar, BorderLayout.SOUTH); setIcon(ImageUtilities.loadImage(ICON, true)); updateDisplayName(dir); @@ -277,11 +294,18 @@ public ClaudeSessionTab(File dir) { public static void openNewOrFocus() { SwingUtilities.invokeLater(() -> { for (TopComponent tc : WindowManager.getDefault().getRegistry().getOpened()) { - if (tc instanceof ClaudeSessionTab tab && !tab.isSessionActive()) { - tab.requestActive(); - return; + if (tc instanceof ClaudeSessionTab tab) { + boolean active = tab.isSessionActive(); + LOG.fine("openNewOrFocus: found tab path=" + tab.getWorkingDirectory() + + " isSessionActive=" + active); + if (!active) { + LOG.fine("openNewOrFocus: focusing idle tab"); + tab.requestActive(); + return; + } } } + LOG.fine("openNewOrFocus: no idle tab found, opening new tab"); ClaudeSessionTab tab = new ClaudeSessionTab(); tab.open(); tab.requestActive(); @@ -331,16 +355,21 @@ protected void componentOpened() { String path = savedPath != null ? savedPath : pathToRestore; String profileName = savedProfileName; String extraCliArgs = savedExtraCliArgs; + String resumeSessionId = savedResumeSessionId; savedPath = null; pathToRestore = null; savedProfileName = null; savedExtraCliArgs = null; + savedResumeSessionId = null; if (path != null && !path.isBlank()) { File dir = new File(path); if (dir.isDirectory()) { + selectorPanel.lock(); WindowManager.getDefault().invokeWhenUIReady( - () -> autoStart(dir, profileName, extraCliArgs)); + resumeSessionId != null + ? () -> autoStart(dir, profileName, extraCliArgs, SessionMode.RESUME_SPECIFIC, resumeSessionId) + : () -> autoStart(dir, profileName, extraCliArgs)); return; } } @@ -386,6 +415,8 @@ public void writeExternal(ObjectOutput out) throws IOException { out.writeUTF(dir != null ? dir.getAbsolutePath() : ""); out.writeUTF(selectorPanel.getSelectedProfileName()); out.writeUTF(selectorPanel.getExtraCliArgs()); + // Only resume ID is saved; on restore, global preference determines mode unless RESUME_SPECIFIC + out.writeUTF(activeResumeSessionId != null ? activeResumeSessionId : ""); } @Override @@ -402,6 +433,12 @@ public void readExternal(ObjectInput in) throws IOException, ClassNotFoundExcept } catch (java.io.EOFException ignored) { savedExtraCliArgs = null; } + try { + String resumeId = in.readUTF(); + savedResumeSessionId = resumeId.isBlank() ? null : resumeId; + } catch (java.io.EOFException ignored) { + savedResumeSessionId = null; + } } // ------------------------------------------------------------------------- @@ -545,13 +582,20 @@ public void autoStart(File dir, String profileName) { } public void autoStart(File dir, String profileName, String extraCliArgs) { + autoStart(dir, profileName, extraCliArgs, + ClaudeCodePreferences.getContextMenuSessionMode(), null); + } + + public void autoStart(File dir, String profileName, String extraCliArgs, + SessionMode mode, String resumeSessionId) { if (dir == null || !dir.isDirectory()) return; selectorPanel.setPath(dir.getAbsolutePath()); selectorPanel.setProfile(profileName); selectorPanel.preselectForDirectory(dir); selectorPanel.setExtraCliArgs(extraCliArgs); selectorPanel.lock(); - startSession(dir, profileName, extraCliArgs != null ? extraCliArgs : ""); + startSession(dir, profileName, extraCliArgs != null ? extraCliArgs : "", + mode != null ? mode : SessionMode.NEW, resumeSessionId); } /** @@ -580,17 +624,33 @@ public String getEditMode() { return model.getEditMode(); } + /** + * Returns the name of the currently selected profile. + * + * @return profile name; never {@code null} + */ + public String getSelectedProfileName() { + return selectorPanel.getSelectedProfileName(); + } + // ------------------------------------------------------------------------- // Session management // ------------------------------------------------------------------------- /** Fired by the selector panel when the user confirms a valid directory. */ - private void onDirectoryOpened(File dir, String profileName, String extraCliArgs) { + private void onDirectoryOpened(File dir, String profileName, String extraCliArgs, + SessionMode mode, String resumeSessionId) { selectorPanel.lock(); - startSession(dir, profileName, extraCliArgs); + startSession(dir, profileName, extraCliArgs, mode, resumeSessionId); } private void startSession(File dir, String profileName, String extraCliArgs) { + startSession(dir, profileName, extraCliArgs, SessionMode.NEW, null); + } + + private void startSession(File dir, String profileName, String extraCliArgs, + SessionMode mode, String resumeSessionId) { + this.activeResumeSessionId = resumeSessionId; updateDisplayName(dir); sessionTag = "[" + dir.getName() + "] "; JediTermWidget widget = new JediTermWidget(new NetBeansSettingsProvider()); @@ -602,7 +662,7 @@ private void startSession(File dir, String profileName, String extraCliArgs) { } showChatLayout(widget); try { - controller.startProcess(dir, profileName, extraCliArgs, widget); + controller.startProcess(dir, profileName, extraCliArgs, mode, resumeSessionId, widget); } catch (IOException ex) { String command = controller.getLastAttemptedCommand(); String errorMsg = ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName(); @@ -611,7 +671,7 @@ private void startSession(File dir, String profileName, String extraCliArgs) { } private void showChatLayout(JediTermWidget widget) { - remove(placeholderLabel); + remove(selectorPanel); selectorPanel.setVisible(false); terminalWidget = widget; @@ -696,8 +756,7 @@ private void showSelectorLayout() { remove(errorPanel); errorPanel = null; } - add(placeholderLabel, BorderLayout.CENTER); - selectorPanel.setVisible(true); + add(selectorPanel, BorderLayout.CENTER); revalidate(); repaint(); } @@ -757,7 +816,7 @@ private void showStartError(String command, String error) { wrapper.add(Box.createVerticalGlue()); errorPanel = wrapper; - remove(placeholderLabel); + remove(selectorPanel); add(errorPanel, BorderLayout.CENTER); revalidate(); repaint(); @@ -816,8 +875,86 @@ private void switchSouthCard(String card) { } } + /** + * Handles the Save & Switch dialog confirmation. + * + *

Stops the current session (writing a custom-title if name changed), + * then either closes the tab or restarts in the same tab with the new mode. + * + * @param sessionName new name for the current session (written to JSONL) + * @param mode what to do after stopping + * @param resumeId session ID to resume (only used for RESUME_SPECIFIC) + */ + /** + * Opens the Save & Switch dialog with the given initial mode pre-selected. + * + * @param initialMode the session mode to pre-select in the dialog + */ + public void openSwitchDialog(SessionMode initialMode) { + File workingDir = model.getWorkingDirectory(); + if (workingDir == null) return; + + String profileName = selectorPanel.getSelectedProfileName(); + Path claudeConfigDir = null; + if (profileName != null && !io.github.nbclaudecodegui.settings.ClaudeProfile.DEFAULT_NAME.equals(profileName)) { + io.github.nbclaudecodegui.settings.ClaudeProfile profile = + io.github.nbclaudecodegui.settings.ClaudeProfileStore.findByName(profileName); + if (profile != null) { + claudeConfigDir = io.github.nbclaudecodegui.settings.ClaudeProfileStore.resolveConfigDir( + profile, ClaudeCodePreferences.getProfilesDir()); + } + } + + io.github.nbclaudecodegui.model.SavedSession recent = + io.github.nbclaudecodegui.process.ClaudeSessionStore.findMostRecent( + workingDir.toPath(), claudeConfigDir); + String currentName = recent != null ? recent.displayName() : ""; + String currentId = recent != null ? recent.sessionId() : null; + + final ClaudeSessionTab self = this; + final Path configDir = claudeConfigDir; + java.awt.Frame mainFrame = org.openide.windows.WindowManager.getDefault().getMainWindow(); + SaveAndSwitchDialog dialog = new SaveAndSwitchDialog( + mainFrame, + currentName, + currentId, + workingDir.toPath(), + configDir, + initialMode, + (name, mode, resumeId) -> + SwingUtilities.invokeLater(() -> self.onSaveAndSwitch(name, mode, resumeId)) + ); + dialog.setDefaultCloseOperation(javax.swing.WindowConstants.DISPOSE_ON_CLOSE); + dialog.setVisible(true); + } + + public void onSaveAndSwitch(String sessionName, SessionMode mode, String resumeId) { + File dir = model.getWorkingDirectory(); + String profileName = selectorPanel.getSelectedProfileName(); + String extraCliArgs = selectorPanel.getExtraCliArgs(); + + // Stop and rename + controller.stopAndRename(sessionName); + cleanupChatLayout(); + + if (mode == SessionMode.CLOSE_ONLY) { + // Just stop — do nothing more + return; + } + + // Restart with new mode + if (dir != null) { + startSession(dir, profileName, extraCliArgs, mode, resumeId); + } + } + private void stopProcess() { controller.stopProcess(); + cleanupChatLayout(); + } + + /** Tears down the chat UI layout after the PTY has already been stopped. */ + private void cleanupChatLayout() { if (terminalWidget != null) { terminalWidget.close(); terminalWidget = null; @@ -837,7 +974,7 @@ private void stopProcess() { } selectorPanel.setVisible(true); selectorPanel.unlock(); - add(placeholderLabel, BorderLayout.CENTER); + add(selectorPanel, BorderLayout.CENTER); statusBar.setVisible(false); currentLifecycle = null; stateLabel.setText("Ready"); diff --git a/src/main/java/io/github/nbclaudecodegui/ui/SaveAndSwitchDialog.java b/src/main/java/io/github/nbclaudecodegui/ui/SaveAndSwitchDialog.java new file mode 100644 index 00000000..4ac5bb95 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/SaveAndSwitchDialog.java @@ -0,0 +1,162 @@ +package io.github.nbclaudecodegui.ui; + +import io.github.nbclaudecodegui.model.SessionMode; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.awt.Window; +import java.nio.file.Path; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingConstants; + +/** + * Modal dialog for saving the current session name and switching the session mode. + * + *

Allows the user to: + *

    + *
  • Rename the current session (writes a {@code custom-title} entry on confirm)
  • + *
  • Choose what happens next: Close only / New session / Continue last / Resume specific
  • + *
+ * + *

Default selection is "Close only". OK is disabled when "Resume specific" is + * selected but no session row is chosen. Cancel closes the dialog without any action. + */ +public final class SaveAndSwitchDialog extends JDialog { + + /** + * Functional interface for the confirm callback. + * + * @param first param type (session name) + * @param second param type (session mode) + * @param third param type (resume session ID) + */ + @FunctionalInterface + public interface TriConsumer { + void accept(A a, B b, C c); + } + + // ------------------------------------------------------------------------- + // Fields + // ------------------------------------------------------------------------- + + private final JTextField nameField; + private final SessionModePanel modePanel; + private final JButton okButton; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + /** + * Creates a Save & Switch dialog. + * + * @param owner parent window + * @param currentSessionName current session display name (pre-fills the name field) + * @param currentSessionId session ID to exclude from the session table + * @param workingDir working directory (used to load session list) + * @param claudeConfigDir Claude config dir ({@code null} → {@code ~/.claude}) + * @param initialMode the session mode to pre-select + * @param onConfirm called with (sessionName, mode, resumeId) when OK is clicked + */ + public SaveAndSwitchDialog(Window owner, + String currentSessionName, + String currentSessionId, + Path workingDir, + Path claudeConfigDir, + SessionMode initialMode, + TriConsumer onConfirm) { + super(owner, "Save & Switch", ModalityType.APPLICATION_MODAL); + + // --- Name field --- + nameField = new JTextField(currentSessionName != null ? currentSessionName : "", 30); + + JPanel nameRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 4)); + nameRow.add(new JLabel("Save current session with name:")); + nameRow.add(nameField); + + // --- Session mode panel --- + modePanel = new SessionModePanel(workingDir, claudeConfigDir, + currentSessionId, true); + modePanel.setMode(initialMode != null ? initialMode : SessionMode.CLOSE_ONLY); + + // --- Buttons --- + okButton = new JButton("OK"); + JButton cancelButton = new JButton("Cancel"); + + JPanel buttonRow = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 4)); + buttonRow.add(okButton); + buttonRow.add(cancelButton); + + // --- Layout --- + JPanel content = new JPanel(new BorderLayout(4, 4)); + content.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + content.add(nameRow, BorderLayout.NORTH); + content.add(modePanel, BorderLayout.CENTER); + content.add(buttonRow, BorderLayout.SOUTH); + + setContentPane(content); + pack(); + setMinimumSize(getPreferredSize()); + setLocationRelativeTo(owner); + + // Double-click on session row → confirm + modePanel.setOnDoubleClick(() -> okButton.doClick()); + + // --- Validation --- + updateOkButton(); + modePanel.addPropertyChangeListener(e -> updateOkButton()); + // Re-check on radio changes via table selection + okButton.addPropertyChangeListener(e -> {}); + + // Attach mode panel listener by overriding isSelectionValid polling via button + // We listen to the mode panel's internal radio buttons indirectly: + // SessionModePanel fires revalidate() which triggers layout, but we need a hook. + // Use a simple approach: validate on every possible event via a shared runnable. + Runnable validate = this::updateOkButton; + for (java.awt.Component c : getAllComponents(modePanel)) { + if (c instanceof javax.swing.JRadioButton rb) { + rb.addActionListener(e2 -> validate.run()); + } + if (c instanceof javax.swing.JTable t) { + t.getSelectionModel().addListSelectionListener(e2 -> validate.run()); + } + } + + // --- Actions --- + okButton.addActionListener(e -> { + String name = nameField.getText().trim(); + SessionMode mode = modePanel.getSelectedMode(); + String resumeId = modePanel.getSelectedSessionId(); + dispose(); + onConfirm.accept(name, mode, resumeId); + }); + + cancelButton.addActionListener(e -> dispose()); + + getRootPane().setDefaultButton(okButton); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void updateOkButton() { + okButton.setEnabled(modePanel.isSelectionValid()); + } + + /** Returns all nested components of the given container. */ + private static java.util.List getAllComponents(java.awt.Container container) { + java.util.List list = new java.util.ArrayList<>(); + for (java.awt.Component c : container.getComponents()) { + list.add(c); + if (c instanceof java.awt.Container ct) { + list.addAll(getAllComponents(ct)); + } + } + return list; + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/ui/SessionModePanel.java b/src/main/java/io/github/nbclaudecodegui/ui/SessionModePanel.java new file mode 100644 index 00000000..f76f1b0e --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/SessionModePanel.java @@ -0,0 +1,392 @@ +package io.github.nbclaudecodegui.ui; + +import io.github.nbclaudecodegui.model.SavedSession; +import io.github.nbclaudecodegui.model.SessionMode; +import io.github.nbclaudecodegui.process.ClaudeSessionStore; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.FlowLayout; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.logging.Logger; +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.ButtonGroup; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JRadioButton; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.ListSelectionModel; +import javax.swing.RowSorter; +import javax.swing.SortOrder; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.TableRowSorter; + +/** + * Reusable panel for selecting how a Claude Code session is started. + * + *

Shows radio buttons for session mode and, when "Resume specific" is + * selected, a table of available sessions. + * + *

Construct with {@code showCloseOnly=false} for the session selector (no + * "Close only" option) and {@code showCloseOnly=true} for the Save & Switch + * dialog. + */ +public final class SessionModePanel extends JPanel { + + private static final Logger LOG = Logger.getLogger(SessionModePanel.class.getName()); + private static final SimpleDateFormat DATE_FMT = + new SimpleDateFormat("yyyy-MM-dd HH:mm"); + + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + private Path workingDir; + private Path claudeConfigDir; + private String excludeSession; + + // ------------------------------------------------------------------------- + // UI + // ------------------------------------------------------------------------- + + private final JRadioButton closeOnlyRadio; + private final JRadioButton newRadio; + private final JRadioButton continueRadio; + private final JRadioButton resumeRadio; + + private final JTable sessionTable; + private final SessionTableModel tableModel; + private final JScrollPane tableScroll; + private final JButton renameButton; + + /** Called on double-click when resumeRadio is selected; may be {@code null}. */ + private Runnable onDoubleClick; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + /** + * Creates a new session-mode panel. + * + * @param workingDir session working directory; may be {@code null} + * @param claudeConfigDir resolved config dir for the active profile; {@code null} → {@code ~/.claude} + * @param excludeSession session ID to exclude from the table (current session), or {@code null} + * @param showCloseOnly if {@code true}, adds a "Close only" radio at the top + */ + public SessionModePanel(Path workingDir, Path claudeConfigDir, + String excludeSession, boolean showCloseOnly) { + this.workingDir = workingDir; + this.claudeConfigDir = claudeConfigDir; + this.excludeSession = excludeSession; + + setLayout(new BorderLayout()); + setBorder(BorderFactory.createEmptyBorder(4, 0, 4, 0)); + + ButtonGroup group = new ButtonGroup(); + + closeOnlyRadio = new JRadioButton("Close only"); + newRadio = new JRadioButton("New session"); + continueRadio = new JRadioButton("Continue last"); + resumeRadio = new JRadioButton("Resume specific"); + + group.add(closeOnlyRadio); + group.add(newRadio); + group.add(continueRadio); + group.add(resumeRadio); + + // Radio panel (NORTH) — keeps all radios grouped together + JPanel radioPanel = new JPanel(); + radioPanel.setLayout(new BoxLayout(radioPanel, BoxLayout.Y_AXIS)); + + if (showCloseOnly) { + radioPanel.add(row(closeOnlyRadio)); + } + radioPanel.add(row(newRadio)); + radioPanel.add(row(continueRadio)); + + // Resume specific row with Rename button + JPanel resumeRow = new JPanel(new BorderLayout()); + resumeRow.setAlignmentX(Component.LEFT_ALIGNMENT); + resumeRow.add(resumeRadio, BorderLayout.WEST); + renameButton = new JButton("Rename"); + renameButton.setEnabled(false); + renameButton.setVisible(false); + JPanel renamePad = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + renamePad.add(renameButton); + resumeRow.add(renamePad, BorderLayout.EAST); + radioPanel.add(resumeRow); + + add(radioPanel, BorderLayout.NORTH); + + // Session table (CENTER) — shown/hidden when Resume specific is selected + tableModel = new SessionTableModel(); + sessionTable = new JTable(tableModel); + sessionTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + sessionTable.setAutoCreateRowSorter(true); + + TableRowSorter sorter = new TableRowSorter<>(tableModel); + sessionTable.setRowSorter(sorter); + List sortKeys = new ArrayList<>(); + sortKeys.add(new RowSorter.SortKey(0, SortOrder.DESCENDING)); + sorter.setSortKeys(sortKeys); + + tableScroll = new JScrollPane(sessionTable); + tableScroll.setPreferredSize(new java.awt.Dimension(400, 150)); + tableScroll.setVisible(false); + add(tableScroll, BorderLayout.CENTER); + + tableScroll.getViewport().addComponentListener(new java.awt.event.ComponentAdapter() { + @Override + public void componentResized(java.awt.event.ComponentEvent e) { + int w = tableScroll.getViewport().getWidth(); + if (w <= 0) return; + int c0 = w / 6; + int c1 = w * 2 / 6; + int c2 = w - c0 - c1; + javax.swing.table.TableColumnModel cm = sessionTable.getColumnModel(); + cm.getColumn(0).setPreferredWidth(c0); + cm.getColumn(1).setPreferredWidth(c1); + cm.getColumn(2).setPreferredWidth(c2); + } + }); + + // Default selection + if (showCloseOnly) { + closeOnlyRadio.setSelected(true); + } else { + continueRadio.setSelected(true); + } + + // Radio listeners + closeOnlyRadio.addActionListener(e -> onRadioChanged()); + newRadio.addActionListener(e -> onRadioChanged()); + continueRadio.addActionListener(e -> onRadioChanged()); + resumeRadio.addActionListener(e -> onRadioChanged()); + + // Table selection listener → enable Rename + sessionTable.getSelectionModel().addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + renameButton.setEnabled(sessionTable.getSelectedRow() >= 0); + } + }); + + // Rename button + renameButton.addActionListener(e -> onRename()); + + // Double-click on session row → fire onDoubleClick (if set and resumeRadio is selected) + sessionTable.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2 && resumeRadio.isSelected() && onDoubleClick != null) { + onDoubleClick.run(); + } + } + }); + + refreshTable(); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Returns the currently selected session mode. + * + * @return selected {@link SessionMode} + */ + public SessionMode getSelectedMode() { + if (closeOnlyRadio.isSelected()) return SessionMode.CLOSE_ONLY; + if (newRadio.isSelected()) return SessionMode.NEW; + if (continueRadio.isSelected()) return SessionMode.CONTINUE_LAST; + return SessionMode.RESUME_SPECIFIC; + } + + /** + * Returns the selected session ID, or {@code null} if mode is not + * {@link SessionMode#RESUME_SPECIFIC} or no row is selected. + * + * @return session ID or {@code null} + */ + public String getSelectedSessionId() { + if (!resumeRadio.isSelected()) return null; + int viewRow = sessionTable.getSelectedRow(); + if (viewRow < 0) return null; + int modelRow = sessionTable.convertRowIndexToModel(viewRow); + return tableModel.getSessionId(modelRow); + } + + /** + * Programmatically sets the selected mode. + * + * @param mode mode to select + */ + public void setMode(SessionMode mode) { + switch (mode) { + case CLOSE_ONLY -> closeOnlyRadio.setSelected(true); + case NEW -> newRadio.setSelected(true); + case CONTINUE_LAST -> continueRadio.setSelected(true); + case RESUME_SPECIFIC -> resumeRadio.setSelected(true); + } + onRadioChanged(); + } + + /** + * Sets a callback to be called when the user double-clicks a row in the + * session table while the "Resume specific" radio is selected. + * + * @param action callback, or {@code null} to remove + */ + public void setOnDoubleClick(Runnable action) { + this.onDoubleClick = action; + } + + /** + * Reloads the session list for a new directory / profile combination. + * + * @param newWorkingDir new working directory + * @param newClaudeConfigDir new config dir (null → ~/.claude) + * @param newExcludeSession session ID to exclude, or null + */ + public void reload(Path newWorkingDir, Path newClaudeConfigDir, String newExcludeSession) { + this.workingDir = newWorkingDir; + this.claudeConfigDir = newClaudeConfigDir; + this.excludeSession = newExcludeSession; + refreshTable(); + } + + /** + * Returns {@code true} if the current selection is valid: any mode except + * {@link SessionMode#RESUME_SPECIFIC}, or RESUME_SPECIFIC with a row selected. + * + * @return {@code true} if a valid selection is made + */ + public boolean isSelectionValid() { + if (resumeRadio.isSelected()) { + return sessionTable.getSelectedRow() >= 0; + } + return true; + } + + boolean isRenameButtonVisible() { + return renameButton.isVisible(); + } + + // ------------------------------------------------------------------------- + // Internal + // ------------------------------------------------------------------------- + + private JPanel row(JRadioButton radio) { + JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + p.setAlignmentX(Component.LEFT_ALIGNMENT); + p.add(radio); + return p; + } + + private void onRadioChanged() { + boolean showTable = resumeRadio.isSelected(); + tableScroll.setVisible(showTable); + renameButton.setVisible(showTable); + if (showTable) { + refreshTable(); + } + revalidate(); + repaint(); + java.awt.Window w = javax.swing.SwingUtilities.getWindowAncestor(this); + if (w instanceof javax.swing.JDialog) w.pack(); + } + + private void refreshTable() { + if (workingDir == null) { + tableModel.setSessions(List.of()); + return; + } + List sessions = ClaudeSessionStore.listSessions(workingDir, claudeConfigDir); + if (excludeSession != null) { + sessions.removeIf(s -> excludeSession.equals(s.sessionId())); + } + tableModel.setSessions(sessions); + } + + private void onRename() { + int viewRow = sessionTable.getSelectedRow(); + if (viewRow < 0) return; + int modelRow = sessionTable.convertRowIndexToModel(viewRow); + SavedSession session = tableModel.getSession(modelRow); + if (session == null) return; + + String current = session.displayName(); + String newName = JOptionPane.showInputDialog( + this, "Rename session:", current); + if (newName == null || newName.isBlank()) return; + + try { + ClaudeSessionStore.renameSession(workingDir, claudeConfigDir, + session.sessionId(), newName.trim()); + refreshTable(); + } catch (Exception e) { + LOG.warning("Could not rename session: " + e.getMessage()); + JOptionPane.showMessageDialog(this, + "Could not rename session: " + e.getMessage(), + "Rename Error", JOptionPane.ERROR_MESSAGE); + } + } + + // ------------------------------------------------------------------------- + // Table model + // ------------------------------------------------------------------------- + + private static final class SessionTableModel extends AbstractTableModel { + + private List sessions = new ArrayList<>(); + + private static final String[] COLUMNS = {"Date/Time", "Name", "First prompt"}; + + void setSessions(List list) { + this.sessions = new ArrayList<>(list); + fireTableDataChanged(); + } + + SavedSession getSession(int modelRow) { + if (modelRow < 0 || modelRow >= sessions.size()) return null; + return sessions.get(modelRow); + } + + String getSessionId(int modelRow) { + SavedSession s = getSession(modelRow); + return s != null ? s.sessionId() : null; + } + + @Override public int getRowCount() { return sessions.size(); } + @Override public int getColumnCount() { return COLUMNS.length; } + @Override public String getColumnName(int col) { return COLUMNS[col]; } + + @Override + public Object getValueAt(int row, int col) { + SavedSession s = sessions.get(row); + return switch (col) { + case 0 -> s.lastAt() != null + ? DATE_FMT.format(Date.from(s.lastAt())) + : ""; + case 1 -> s.displayName(); + case 2 -> s.firstPrompt() != null ? s.firstPrompt() : ""; + default -> ""; + }; + } + + @Override + public Class getColumnClass(int col) { + return String.class; + } + } +} diff --git a/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusLineElement.java b/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusLineElement.java index 2e732c5d..b41bfe8c 100644 --- a/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusLineElement.java +++ b/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusLineElement.java @@ -1,12 +1,27 @@ package org.openbeans.claude.netbeans; +import io.github.nbclaudecodegui.model.SavedSession; +import io.github.nbclaudecodegui.model.SessionMode; +import io.github.nbclaudecodegui.process.ClaudeSessionStore; +import io.github.nbclaudecodegui.settings.ClaudeCodePreferences; +import io.github.nbclaudecodegui.settings.ClaudeProfile; +import io.github.nbclaudecodegui.settings.ClaudeProfileStore; +import io.github.nbclaudecodegui.ui.ClaudeSessionTab; +import io.github.nbclaudecodegui.ui.SaveAndSwitchDialog; import java.awt.Component; +import java.awt.Frame; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.io.File; +import java.nio.file.Path; import javax.swing.JLabel; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; import org.openide.awt.StatusLineElementProvider; import org.openide.util.Lookup; import org.openide.util.lookup.ServiceProvider; +import org.openide.windows.TopComponent; +import org.openide.windows.WindowManager; /** * Status bar element for Claude Code integration status. @@ -29,13 +44,13 @@ private void initializeStatusLabel() { statusLabel.setText("Claude"); statusLabel.setToolTipText("Claude Code: Initializing..."); - // Add mouse listener for click interactions (future enhancement) + statusLabel.setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)); statusLabel.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { - // Could show status dialog or perform action + onStatusLabelClicked(); } - + @Override public void mouseEntered(MouseEvent e) { updateTooltip(); @@ -83,4 +98,53 @@ public Component getStatusLineElement() { public void refresh() { updateStatusDisplay(); } + + private void onStatusLabelClicked() { + // Find the active ClaudeSessionTab + ClaudeSessionTab activeTab = null; + for (TopComponent tc : WindowManager.getDefault().getRegistry().getOpened()) { + if (tc instanceof ClaudeSessionTab tab && tab.isSessionActive()) { + activeTab = tab; + break; + } + } + if (activeTab == null) return; + + File workingDir = activeTab.getWorkingDirectory(); + if (workingDir == null) return; + + // Resolve claude config dir from the active profile + String profileName = activeTab.getSelectedProfileName(); + Path claudeConfigDir = null; + if (profileName != null && !ClaudeProfile.DEFAULT_NAME.equals(profileName)) { + ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); + if (profile != null) { + claudeConfigDir = ClaudeProfileStore.resolveConfigDir( + profile, ClaudeCodePreferences.getProfilesDir()); + } + } + + // Get current session display name + SavedSession recent = ClaudeSessionStore.findMostRecent( + workingDir.toPath(), claudeConfigDir); + String currentName = recent != null ? recent.displayName() : ""; + String currentId = recent != null ? recent.sessionId() : null; + + final ClaudeSessionTab tab = activeTab; + final Path configDir = claudeConfigDir; + + Frame mainFrame = WindowManager.getDefault().getMainWindow(); + SaveAndSwitchDialog dialog = new SaveAndSwitchDialog( + mainFrame, + currentName, + currentId, + workingDir.toPath(), + configDir, + io.github.nbclaudecodegui.model.SessionMode.CLOSE_ONLY, + (name, mode, resumeId) -> + SwingUtilities.invokeLater(() -> tab.onSaveAndSwitch(name, mode, resumeId)) + ); + dialog.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + dialog.setVisible(true); + } } \ No newline at end of file diff --git a/src/test/java/io/github/nbclaudecodegui/model/SavedSessionTest.java b/src/test/java/io/github/nbclaudecodegui/model/SavedSessionTest.java new file mode 100644 index 00000000..d1001a73 --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/model/SavedSessionTest.java @@ -0,0 +1,45 @@ +package io.github.nbclaudecodegui.model; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +class SavedSessionTest { + + @Test + void displayName_returnsCustomTitleWhenSet() { + SavedSession s = new SavedSession("id", Instant.now(), Instant.now(), + "auto-slug", "My Custom Title", null); + assertEquals("My Custom Title", s.displayName()); + } + + @Test + void displayName_returnsSlugWhenNoCustomTitle() { + SavedSession s = new SavedSession("id", Instant.now(), Instant.now(), + "auto-slug", null, null); + assertEquals("auto-slug", s.displayName()); + } + + @Test + void displayName_returnsSlugWhenCustomTitleIsBlank() { + SavedSession s = new SavedSession("id", Instant.now(), Instant.now(), + "auto-slug", " ", null); + assertEquals("auto-slug", s.displayName()); + } + + @Test + void displayName_returnsSessionIdWhenSlugIsNull() { + SavedSession s = new SavedSession("my-session-id", Instant.now(), Instant.now(), + null, null, null); + assertEquals("my-session-id", s.displayName()); + } + + @Test + void displayName_returnsSessionIdWhenBothNull() { + SavedSession s = new SavedSession("fallback-id", Instant.now(), Instant.now(), + null, null, null); + assertEquals("fallback-id", s.displayName()); + } +} diff --git a/src/test/java/io/github/nbclaudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbclaudecodegui/process/ClaudeSessionStoreTest.java new file mode 100644 index 00000000..cb923535 --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/process/ClaudeSessionStoreTest.java @@ -0,0 +1,198 @@ +package io.github.nbclaudecodegui.process; + +import io.github.nbclaudecodegui.model.SavedSession; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ClaudeSessionStoreTest { + + @TempDir Path tmpDir; + + // ------------------------------------------------------------------------- + // computeHash + // ------------------------------------------------------------------------- + + @Test + void computeHash_replacesSeparatorsWithDashes() { + Path p = Path.of("/home/user/my-project"); + assertEquals("-home-user-my-project", ClaudeSessionStore.computeHash(p)); + } + + @Test + void computeHash_singleSegment() { + Path p = Path.of("/myproject"); + assertEquals("-myproject", ClaudeSessionStore.computeHash(p)); + } + + // ------------------------------------------------------------------------- + // listSessions + // ------------------------------------------------------------------------- + + private Path createSessionDir(Path configDir, Path workingDir) throws IOException { + String hash = ClaudeSessionStore.computeHash(workingDir); + Path sessionDir = configDir.resolve("projects").resolve(hash); + Files.createDirectories(sessionDir); + return sessionDir; + } + + private void writeJsonl(Path file, String... lines) throws IOException { + StringBuilder sb = new StringBuilder(); + for (String line : lines) sb.append(line).append("\n"); + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + } + + @Test + void listSessions_parsesSlugAndTimestamps() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path jsonl = sessionDir.resolve("abc123.jsonl"); + writeJsonl(jsonl, + """ + {"sessionId":"abc123","slug":"enchanted-scribbling","timestamp":"2024-01-01T10:00:00.000Z","type":"summary"}""", + """ + {"type":"user","content":"hello","timestamp":"2024-01-01T10:05:00.000Z","sessionId":"abc123"}""" + ); + + List sessions = ClaudeSessionStore.listSessions(workDir, tmpDir); + assertEquals(1, sessions.size()); + SavedSession s = sessions.get(0); + assertEquals("abc123", s.sessionId()); + assertEquals("enchanted-scribbling", s.slug()); + assertNull(s.customTitle()); + assertEquals("enchanted-scribbling", s.displayName()); + } + + @Test + void listSessions_customTitleOverridesSlug() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path jsonl = sessionDir.resolve("abc123.jsonl"); + writeJsonl(jsonl, + """ + {"sessionId":"abc123","slug":"old-slug","timestamp":"2024-01-01T10:00:00.000Z","type":"summary"}""", + """ + {"type":"custom-title","customTitle":"My Title","sessionId":"abc123","timestamp":"2024-01-01T11:00:00.000Z"}""" + ); + + List sessions = ClaudeSessionStore.listSessions(workDir, tmpDir); + assertEquals(1, sessions.size()); + assertEquals("My Title", sessions.get(0).customTitle()); + assertEquals("My Title", sessions.get(0).displayName()); + } + + @Test + void listSessions_lastCustomTitleWins() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path jsonl = sessionDir.resolve("abc123.jsonl"); + writeJsonl(jsonl, + """ + {"sessionId":"abc123","slug":"slug","timestamp":"2024-01-01T10:00:00.000Z","type":"summary"}""", + """ + {"type":"custom-title","customTitle":"First","sessionId":"abc123","timestamp":"2024-01-01T10:01:00.000Z"}""", + """ + {"type":"custom-title","customTitle":"Second","sessionId":"abc123","timestamp":"2024-01-01T10:02:00.000Z"}""" + ); + + List sessions = ClaudeSessionStore.listSessions(workDir, tmpDir); + assertEquals("Second", sessions.get(0).customTitle()); + } + + @Test + void listSessions_excludesSessionsWithNoTimestamps() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeJsonl(sessionDir.resolve("nots.jsonl"), + """ + {"sessionId":"nots","slug":"no-ts","type":"summary"}""" + ); + + List sessions = ClaudeSessionStore.listSessions(workDir, tmpDir); + assertTrue(sessions.isEmpty()); + } + + @Test + void listSessions_sortsByLastAtDesc() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + + writeJsonl(sessionDir.resolve("old.jsonl"), + """ + {"sessionId":"old","slug":"old-session","timestamp":"2024-01-01T09:00:00.000Z","type":"summary"}""" + ); + writeJsonl(sessionDir.resolve("new.jsonl"), + """ + {"sessionId":"new","slug":"new-session","timestamp":"2024-06-01T09:00:00.000Z","type":"summary"}""" + ); + + List sessions = ClaudeSessionStore.listSessions(workDir, tmpDir); + assertEquals(2, sessions.size()); + assertEquals("new", sessions.get(0).sessionId()); + assertEquals("old", sessions.get(1).sessionId()); + } + + // ------------------------------------------------------------------------- + // deleteSession + // ------------------------------------------------------------------------- + + @Test + void deleteSession_removesFile() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path jsonl = sessionDir.resolve("xyz.jsonl"); + writeJsonl(jsonl, """ + {"sessionId":"xyz","slug":"s","timestamp":"2024-01-01T10:00:00.000Z","type":"summary"}"""); + + assertTrue(ClaudeSessionStore.deleteSession(workDir, tmpDir, "xyz")); + assertFalse(Files.exists(jsonl)); + } + + @Test + void deleteSession_returnsFalseOnSecondCall() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path jsonl = sessionDir.resolve("xyz.jsonl"); + writeJsonl(jsonl, """ + {"sessionId":"xyz","slug":"s","timestamp":"2024-01-01T10:00:00.000Z","type":"summary"}"""); + + assertTrue(ClaudeSessionStore.deleteSession(workDir, tmpDir, "xyz")); + assertFalse(ClaudeSessionStore.deleteSession(workDir, tmpDir, "xyz")); + } + + // ------------------------------------------------------------------------- + // renameSession + // ------------------------------------------------------------------------- + + @Test + void renameSession_appendsCustomTitleLine() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path jsonl = sessionDir.resolve("abc.jsonl"); + writeJsonl(jsonl, + """ + {"sessionId":"abc","slug":"slug","timestamp":"2024-01-01T10:00:00.000Z","type":"summary"}""" + ); + + ClaudeSessionStore.renameSession(workDir, tmpDir, "abc", "My New Name"); + + List sessions = ClaudeSessionStore.listSessions(workDir, tmpDir); + assertEquals(1, sessions.size()); + assertEquals("My New Name", sessions.get(0).customTitle()); + assertEquals("My New Name", sessions.get(0).displayName()); + } + + @Test + void renameSession_throwsWhenFileNotFound() { + Path workDir = tmpDir.resolve("notexist"); + assertThrows(IOException.class, () -> + ClaudeSessionStore.renameSession(workDir, tmpDir, "no-such-id", "Name")); + } +} diff --git a/src/test/java/io/github/nbclaudecodegui/ui/ClaudePromptPanelSendTest.java b/src/test/java/io/github/nbclaudecodegui/ui/ClaudePromptPanelSendTest.java index c177355b..df3a8872 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/ClaudePromptPanelSendTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/ClaudePromptPanelSendTest.java @@ -39,7 +39,7 @@ private void buildPanel(String workingDir) throws Exception { Runnable noop = () -> {}; SwingUtilities.invokeAndWait(() -> { - panel = new ClaudePromptPanel(send, noop, noop, hist, wd); + panel = new ClaudePromptPanel(send, noop, noop, hist, wd, null, null); }); } diff --git a/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanelTest.java b/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanelTest.java index 51fde7b6..0f723191 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanelTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanelTest.java @@ -28,7 +28,7 @@ class ClaudeSessionSelectorPanelTest { * happens (avoids project-API calls). */ private static ClaudeSessionSelectorPanel panel() { - return new ClaudeSessionSelectorPanel(null, (dir, profile, extra) -> {}); + return new ClaudeSessionSelectorPanel(null, (dir, profile, extra, mode, resumeId) -> {}); } // ------------------------------------------------------------------------- @@ -67,7 +67,7 @@ void openCallbackFiredForValidDir() throws IOException { AtomicReference capturedDir = new AtomicReference<>(); AtomicReference capturedProfile = new AtomicReference<>(); - ClaudeSessionSelectorPanel p = new ClaudeSessionSelectorPanel(null, (dir, profile, extra) -> { + ClaudeSessionSelectorPanel p = new ClaudeSessionSelectorPanel(null, (dir, profile, extra, mode, resumeId) -> { capturedDir.set(dir); capturedProfile.set(profile); }); @@ -90,7 +90,7 @@ void openCallbackFiredForValidDir() throws IOException { @Test void openDoesNotFireForEmptyPath() { AtomicReference capturedDir = new AtomicReference<>(); - ClaudeSessionSelectorPanel p = new ClaudeSessionSelectorPanel(null, (dir, profile, extra) -> + ClaudeSessionSelectorPanel p = new ClaudeSessionSelectorPanel(null, (dir, profile, extra, mode, resumeId) -> capturedDir.set(dir)); p.setPath(""); @@ -102,7 +102,7 @@ void openDoesNotFireForEmptyPath() { @Test void openDoesNotFireForMissingDir() { AtomicReference capturedDir = new AtomicReference<>(); - ClaudeSessionSelectorPanel p = new ClaudeSessionSelectorPanel(null, (dir, profile, extra) -> + ClaudeSessionSelectorPanel p = new ClaudeSessionSelectorPanel(null, (dir, profile, extra, mode, resumeId) -> capturedDir.set(dir)); p.setPath("/this/path/does/not/exist/hopefully-" + System.nanoTime()); diff --git a/src/test/java/io/github/nbclaudecodegui/ui/SessionModePanelTest.java b/src/test/java/io/github/nbclaudecodegui/ui/SessionModePanelTest.java new file mode 100644 index 00000000..2548bc6a --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/SessionModePanelTest.java @@ -0,0 +1,84 @@ +package io.github.nbclaudecodegui.ui; + +import io.github.nbclaudecodegui.model.SessionMode; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SessionModePanelTest { + + @Test + void isSelectionValid_falseForResumeSpecificWithNoRowSelected() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + assertFalse(panel.isSelectionValid(), + "RESUME_SPECIFIC with no row selected should be invalid"); + } + + @Test + void isSelectionValid_trueForNewMode() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + panel.setMode(SessionMode.NEW); + assertTrue(panel.isSelectionValid()); + } + + @Test + void isSelectionValid_trueForContinueLast() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + panel.setMode(SessionMode.CONTINUE_LAST); + assertTrue(panel.isSelectionValid()); + } + + @Test + void isSelectionValid_trueForCloseOnly() { + SessionModePanel panel = new SessionModePanel(null, null, null, true); + panel.setMode(SessionMode.CLOSE_ONLY); + assertTrue(panel.isSelectionValid()); + } + + @Test + void getSelectedMode_defaultIsContinueLast_whenShowCloseOnlyFalse() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + assertEquals(SessionMode.CONTINUE_LAST, panel.getSelectedMode()); + } + + @Test + void getSelectedMode_defaultIsCloseOnly_whenShowCloseOnlyTrue() { + SessionModePanel panel = new SessionModePanel(null, null, null, true); + assertEquals(SessionMode.CLOSE_ONLY, panel.getSelectedMode()); + } + + @Test + void getSelectedSessionId_nullWhenNotResumeSpecific() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + panel.setMode(SessionMode.NEW); + assertNull(panel.getSelectedSessionId()); + } + + @Test + void renameButton_hiddenWhenNewModeSelected() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + panel.setMode(SessionMode.NEW); + assertFalse(panel.isRenameButtonVisible(), "Rename button should be hidden when NEW mode is selected"); + } + + @Test + void renameButton_hiddenWhenContinueLastSelected() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + panel.setMode(SessionMode.CONTINUE_LAST); + assertFalse(panel.isRenameButtonVisible(), "Rename button should be hidden when CONTINUE_LAST mode is selected"); + } + + @Test + void renameButton_visibleWhenResumeSpecificSelected() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + assertTrue(panel.isRenameButtonVisible(), "Rename button should be visible when RESUME_SPECIFIC mode is selected"); + } + + @Test + void renameButton_hiddenByDefault() { + SessionModePanel panel = new SessionModePanel(null, null, null, false); + assertFalse(panel.isRenameButtonVisible(), "Rename button should be hidden by default (CONTINUE_LAST is default)"); + } +}