Skip to content

Commit b2fe3eb

Browse files
committed
Merge branch dev into published
2 parents bcb13b1 + 96a7cb4 commit b2fe3eb

12 files changed

Lines changed: 338 additions & 23 deletions

.github/copilot-instructions.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## Design Context
2+
3+
### Users
4+
5+
VS Code developers adopting or practicing Clojure/ClojureScript. The audience spans from **complete Clojure beginners** (the explicit growth mission) to **experienced Clojurians** who chose VS Code as their editor. They arrive with VS Code muscle memory and expectations — syntax highlighting, command palette, familiar keybindings — and need the REPL to feel like a natural extension of that, not a foreign system bolted on.
6+
7+
The context of use is deep-focus programming: long sessions, high cognitive load, frequent context-switching between code and REPL output. The interface must never compete for attention with the code itself.
8+
9+
### Brand Personality
10+
11+
**Approachable, precise, alive.**
12+
13+
Calva is named after Calvados — a spirit that gains its character from what it's distilled from (CIDER/nREPL) and what it matures in (VS Code). The brand voice is that of a master distiller: confident but unhurried, opinionated but welcoming, spartan but *not* poor. As the Tao states: "VS Code and Clojure brought together has the capacity to create something amazingly rich and luxurious."
14+
15+
The emotional goals are **confidence** (I know what's happening), **flow** (nothing breaks my concentration), and **precision** (sharp, exact, professional). The anti-reference is GitLens — invasive, attention-grabbing, too much visual presence in the editor.
16+
17+
### Aesthetic Direction
18+
19+
- **Theme**: Native VS Code. Custom surfaces (webviews, output panels) should feel like they belong to the user's chosen VS Code theme, not to Calva's own visual system. Use `var(--vscode-*)` CSS custom properties as the primary palette.
20+
- **Brand color**: Golden amber `#db9550` — core brand accent. Use sparingly: status indicators, the Calva logo, moments of identity. Never as a dominant surface color.
21+
- **Font**: Fira Code is the bundled code font for webviews. Body/UI text inherits from VS Code's editor font family.
22+
- **Tone**: Quiet competence. The interface should feel like a well-made tool — present when needed, invisible when not. Spartan in the Halloway sense: few features, each done right.
23+
- **Anti-patterns**: Invasive decorations, unsolicited overlays, attention-competing UI, feature clutter. Calva should never make the user aware of Calva when they're trying to think about Clojure.
24+
25+
### Design Principles
26+
27+
1. **The VS Code way is Calva's way.** Leverage existing VS Code patterns and conventions. What's old is old; what's new should be as easy as possible to pick up. Don't invent new interaction patterns when VS Code already has one.
28+
29+
2. **Remove obstacles to the REPL.** Every UI decision should be evaluated by: does this help or hinder the developer's path to evaluating code and understanding results? The REPL connection is the critical moment; evaluation results are the critical data.
30+
31+
3. **Spartan, not poor.** Resist feature creep. Few knobs, sane defaults. But the knobs that exist should feel luxurious — well-considered, well-placed, well-documented. Quality over quantity.
32+
33+
4. **Simplify the complex.** Calva has organic complexity (multiple output destinations, REPL window maintenance, session routing). Design should actively work to reduce cognitive load around these areas rather than expose the underlying complexity.
34+
35+
5. **Invisible when working, present when needed.** Status information, connection state, session routing — these should be discoverable but not intrusive. The developer's attention belongs to their code, not to Calva's UI.

.impeccable.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Calva Design Context
2+
3+
## Users
4+
5+
VS Code developers adopting or practicing Clojure/ClojureScript. The audience spans from **complete Clojure beginners** (the explicit growth mission) to **experienced Clojurians** who chose VS Code as their editor. They arrive with VS Code muscle memory and expectations — syntax highlighting, command palette, familiar keybindings — and need the REPL to feel like a natural extension of that, not a foreign system bolted on.
6+
7+
The context of use is deep-focus programming: long sessions, high cognitive load, frequent context-switching between code and REPL output. The interface must never compete for attention with the code itself.
8+
9+
## Brand Personality
10+
11+
**Approachable, precise, alive.**
12+
13+
Calva is named after Calvados — a spirit that gains its character from what it's distilled from (CIDER/nREPL) and what it matures in (VS Code). The brand voice is that of a master distiller: confident but unhurried, opinionated but welcoming, spartan but *not* poor. As the Tao states: "VS Code and Clojure brought together has the capacity to create something amazingly rich and luxurious."
14+
15+
The emotional goals are **confidence** (I know what's happening), **flow** (nothing breaks my concentration), and **precision** (sharp, exact, professional). The anti-reference is GitLens — invasive, attention-grabbing, too much visual presence in the editor.
16+
17+
## Aesthetic Direction
18+
19+
- **Theme**: Native VS Code. Custom surfaces (webviews, output panels) should feel like they belong to the user's chosen VS Code theme, not to Calva's own visual system. Use `var(--vscode-*)` CSS custom properties as the primary palette.
20+
- **Brand color**: Golden amber `#db9550` — core brand accent. Use sparingly: status indicators, the Calva logo, moments of identity. Never as a dominant surface color.
21+
- **Font**: Fira Code is the bundled code font for webviews. Body/UI text inherits from VS Code's editor font family.
22+
- **Tone**: Quiet competence. The interface should feel like a well-made tool — present when needed, invisible when not. Spartan in the Halloway sense: few features, each done right.
23+
- **Anti-patterns**: Invasive decorations, unsolicited overlays, attention-competing UI, feature clutter. Calva should never make the user aware of Calva when they're trying to think about Clojure.
24+
25+
## Design Principles
26+
27+
1. **The VS Code way is Calva's way.** Leverage existing VS Code patterns and conventions. What's old is old; what's new should be as easy as possible to pick up. Don't invent new interaction patterns when VS Code already has one.
28+
29+
2. **Remove obstacles to the REPL.** Every UI decision should be evaluated by: does this help or hinder the developer's path to evaluating code and understanding results? The REPL connection is the critical moment; evaluation results are the critical data.
30+
31+
3. **Spartan, not poor.** Resist feature creep. Few knobs, sane defaults. But the knobs that exist should feel luxurious — well-considered, well-placed, well-documented. Quality over quantity.
32+
33+
4. **Simplify the complex.** Calva has organic complexity (multiple output destinations, REPL window maintenance, session routing). Design should actively work to reduce cognitive load around these areas rather than expose the underlying complexity.
34+
35+
5. **Invisible when working, present when needed.** Status information, connection state, session routing — these should be discoverable but not intrusive. The developer's attention belongs to their code, not to Calva's UI.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ Changes to Calva.
44

55
## [Unreleased]
66

7+
## [2.0.583] - 2026-05-04
8+
9+
- [Enable renaming of REPL sessions](https://github.com/BetterThanTomorrow/calva/issues/3197)
10+
- Fix: [Calva disconnects and closed the WebSocket server when connecting a websocket REPL configured for the same port](https://github.com/BetterThanTomorrow/calva/issues/3198)
11+
712
## [2.0.582] - 2026-05-03
813

914
- [Make Orphaned/dicsonnected WebSocket servers closable](https://github.com/BetterThanTomorrow/calva/issues/3195)

docs/site/repl-ui.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Calva keeps every nREPL connection alive until you explicitly disconnect it. Thi
2525

2626
E.g. connect three Babashka repls and you will have one session named `bb` another named `bb:2`, and a third named `bb:3`. If you then connect two Clojure + ClojureScript repls using default session names, you will have four more sessions named: `clj`, `cljs`, `clj:4`, `cljs:4`.
2727

28+
- You can rename any session by clicking the pencil icon next to it in the sessions menu. This lets you give sessions meaningful names like `epupp-youtube` instead of the auto-generated `epupp:2`. Renames take effect immediately for routing and evaluation, but do not persist across reconnection.
29+
2830
![REPL Sessions Menu](images/repl-ui/repl-sessions-menu.png)
2931

3032
## UI Components Overview

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "Calva: Clojure & ClojureScript Interactive Programming",
44
"description": "Integrated REPL, formatter, Paredit, and more. Powered by cider-nrepl and clojure-lsp.",
55
"icon": "assets/calva.png",
6-
"version": "2.0.582",
6+
"version": "2.0.583",
77
"publisher": "betterthantomorrow",
88
"author": {
99
"name": "Better Than Tomorrow",

src/connector.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -103,30 +103,18 @@ async function connectViaWebSocket(
103103
const projectRoot = state.getProjectRootUri().toString();
104104
const useSecondarySession = secondarySession.shouldUseSecondarySession(connectSequence);
105105

106+
// WebSocket connections skip reconnection candidate detection entirely.
107+
// Each WS server is single-client, so multiple browser tabs require separate
108+
// servers on different ports. The port-in-use prompt handles conflicts naturally.
106109
const resolution = sessionNameResolver.resolveSessionNames(
107110
baseSessionNames,
108111
projectRoot,
109112
wsHost,
110-
isJackIn ? null : wsPort
113+
wsPort,
114+
{ skipReconnect: true }
111115
);
112116
const sessionRoleKeys = resolution.finalNames;
113117

114-
if (resolution.reconnectClientKey) {
115-
output.appendLineOtherOut(
116-
`Reconnecting: disconnecting existing client for sessions: ${Object.values(sessionRoleKeys)
117-
.filter(Boolean)
118-
.join(', ')}`
119-
);
120-
if (isJackIn) {
121-
await jackIn.stopJackInProcessesByClientKey(resolution.reconnectClientKey, {
122-
preserveSuffix: true,
123-
});
124-
}
125-
if (clientRegistry.getClient(resolution.reconnectClientKey)) {
126-
await disconnectClientByKey(resolution.reconnectClientKey, { preserveSuffix: true });
127-
}
128-
}
129-
130118
const sessionGlobMap = sessionRoleUtils.deriveSessionGlobMap(
131119
connectSequence,
132120
sessionRoleKeys,

src/extension-test/unit/nrepl/session-registry-test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import * as expectLib from 'expect';
22
import type * as nrepl from '../../../../src/nrepl';
33
import * as sessionRegistry from '../../../../src/nrepl/session-registry';
44
import * as clientRegistry from '../../../../src/nrepl/client-registry';
5+
import * as sessionNameSuffix from '../../../../src/nrepl/session-name-suffix';
56

67
describe('session registry', () => {
78
afterEach(() => {
89
sessionRegistry._testUtility_registeredSessions.clear();
910
sessionRegistry.setClojureDocsSessionKey(null);
1011
clientRegistry._testUtility_registeredClients.clear();
12+
sessionNameSuffix.resetPool();
1113
});
1214

1315
describe('resolveSessionKey', () => {
@@ -148,4 +150,88 @@ describe('session registry', () => {
148150
expectLib.expect((mainForB as any)?._calvaSessionMetadata?.key).toBe('clj-b');
149151
});
150152
});
153+
154+
describe('renameSession', () => {
155+
const createSession = (clientKey: string): nrepl.NReplSession =>
156+
({ client: { clientKey } } as unknown as nrepl.NReplSession);
157+
158+
const createMockClient = (clientKey: string) =>
159+
({ clientKey } as unknown as Parameters<typeof clientRegistry.registerClient>[0]);
160+
161+
it('renames session: accessible under new key, not under old', () => {
162+
sessionRegistry.registerSession('epupp', createSession('client-a'), {});
163+
164+
const result = sessionRegistry.renameSession('epupp', 'epupp-youtube');
165+
166+
expectLib.expect(result).toBe(true);
167+
expectLib.expect(sessionRegistry.getSession('epupp-youtube')).toBeDefined();
168+
expectLib.expect(sessionRegistry.getSession('epupp')).toBeUndefined();
169+
});
170+
171+
it('returns false if old key not found', () => {
172+
const result = sessionRegistry.renameSession('nonexistent', 'new-name');
173+
174+
expectLib.expect(result).toBe(false);
175+
});
176+
177+
it('returns false if new key already exists', () => {
178+
sessionRegistry.registerSession('alpha', createSession('client-a'), {});
179+
sessionRegistry.registerSession('beta', createSession('client-b'), {});
180+
181+
const result = sessionRegistry.renameSession('alpha', 'beta');
182+
183+
expectLib.expect(result).toBe(false);
184+
expectLib.expect(sessionRegistry.getSession('alpha')).toBeDefined();
185+
});
186+
187+
it('updates sessionRoleKeys for primary session', () => {
188+
clientRegistry.registerClient(createMockClient('client-a'), {
189+
connectionState: {
190+
sessionRoleKeys: { primary: 'clj', secondary: 'cljs' },
191+
},
192+
});
193+
sessionRegistry.registerSession('clj', createSession('client-a'), {});
194+
sessionRegistry.registerSession('cljs', createSession('client-a'), { isSecondary: true });
195+
196+
sessionRegistry.renameSession('clj', 'my-clj');
197+
198+
const state = clientRegistry.getConnectionState('client-a');
199+
expectLib.expect(state?.sessionRoleKeys?.primary).toBe('my-clj');
200+
expectLib.expect(state?.sessionRoleKeys?.secondary).toBe('cljs');
201+
});
202+
203+
it('updates sessionRoleKeys for secondary session', () => {
204+
clientRegistry.registerClient(createMockClient('client-a'), {
205+
connectionState: {
206+
sessionRoleKeys: { primary: 'clj', secondary: 'cljs' },
207+
},
208+
});
209+
sessionRegistry.registerSession('clj', createSession('client-a'), {});
210+
sessionRegistry.registerSession('cljs', createSession('client-a'), { isSecondary: true });
211+
212+
sessionRegistry.renameSession('cljs', 'my-cljs');
213+
214+
const state = clientRegistry.getConnectionState('client-a');
215+
expectLib.expect(state?.sessionRoleKeys?.primary).toBe('clj');
216+
expectLib.expect(state?.sessionRoleKeys?.secondary).toBe('my-cljs');
217+
});
218+
219+
it('releases suffix when renaming away from suffixed name', () => {
220+
const suffix = sessionNameSuffix.acquireNextAvailableSuffix();
221+
expectLib.expect(suffix).toBe('2');
222+
223+
clientRegistry.registerClient(createMockClient('client-a'), {
224+
connectionState: {
225+
sessionRoleKeys: { primary: 'epupp:2' },
226+
},
227+
});
228+
sessionRegistry.registerSession('epupp:2', createSession('client-a'), {});
229+
230+
sessionRegistry.renameSession('epupp:2', 'epupp-youtube');
231+
232+
// Suffix "2" should be released back to the pool
233+
const nextSuffix = sessionNameSuffix.acquireNextAvailableSuffix();
234+
expectLib.expect(nextSuffix).toBe('2');
235+
});
236+
});
151237
});

src/extension-test/unit/session-name-resolver-test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,61 @@ describe('session-name-resolver', () => {
285285
});
286286
});
287287

288+
describe('skipReconnect option', () => {
289+
it('skips reconnection and treats as conflict when skipReconnect is true', () => {
290+
const baseNames = { primary: 'clj', secondary: 'cljs' };
291+
const projectRoot = '/project-a';
292+
293+
// Register existing client that would normally trigger reconnection
294+
clientRegistry.registerClient(createMockClient('client-a'), {
295+
projectRoot,
296+
host: 'localhost',
297+
port: 3340,
298+
connectionState: {
299+
baseSessionNames: baseNames,
300+
},
301+
});
302+
sessionRegistry.registerSession('clj', createSession('client-a'), {});
303+
304+
const resolution = sessionNameResolver.resolveSessionNames(
305+
baseNames,
306+
projectRoot,
307+
'localhost',
308+
3340,
309+
{ skipReconnect: true }
310+
);
311+
312+
expectLib.expect(resolution.reconnectClientKey).toBeUndefined();
313+
expectLib.expect(resolution.suffix).toBeDefined();
314+
expectLib.expect(resolution.finalNames.primary).toMatch(/^clj:\w+$/);
315+
expectLib.expect(resolution.finalNames.secondary).toMatch(/^cljs:\w+$/);
316+
});
317+
318+
it('still finds reconnection candidate when skipReconnect is false', () => {
319+
const baseNames = { primary: 'clj', secondary: 'cljs' };
320+
const projectRoot = '/project-a';
321+
322+
clientRegistry.registerClient(createMockClient('client-a'), {
323+
projectRoot,
324+
host: 'localhost',
325+
port: 3340,
326+
connectionState: {
327+
baseSessionNames: baseNames,
328+
},
329+
});
330+
331+
const resolution = sessionNameResolver.resolveSessionNames(
332+
baseNames,
333+
projectRoot,
334+
'localhost',
335+
3340,
336+
{ skipReconnect: false }
337+
);
338+
339+
expectLib.expect(resolution.reconnectClientKey).toBe('client-a');
340+
});
341+
});
342+
288343
describe('reconnection scenario (port-unaware, e.g. jack-in)', () => {
289344
it('detects reconnection when baseNames and projectRoot match (port is null)', () => {
290345
const baseNames = { primary: 'bb' };

src/nrepl/session-name-resolver.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,12 @@ export function resolveSessionNames(
138138
baseNames: sessionRoleUtils.SessionRoleKeys,
139139
projectRoot: string,
140140
host: string,
141-
port: number | null
141+
port: number | null,
142+
options?: { skipReconnect?: boolean }
142143
): SessionNameResolution {
143-
const reconnectClientKey = findReconnectionCandidate(baseNames, projectRoot, host, port);
144+
const reconnectClientKey = options?.skipReconnect
145+
? undefined
146+
: findReconnectionCandidate(baseNames, projectRoot, host, port);
144147
if (reconnectClientKey) {
145148
const existingState = clientRegistry.getConnectionState(reconnectClientKey);
146149
const existingSuffix = existingState?.suffix;

0 commit comments

Comments
 (0)