Skip to content

Commit e319f8b

Browse files
committed
feat: persist chat history in Remote SSH sessions
- Add remote environment detection utility to identify SSH, WSL, Dev Containers, etc. - Modify storage paths to include remote-specific subdirectories - Add configuration option "persistChatInRemote" to control the feature - Ensure chat history is saved per remote workspace and restored on reconnection - Add comprehensive tests for remote storage functionality Fixes #8028
1 parent 3c2fd1d commit e319f8b

File tree

6 files changed

+380
-20
lines changed

6 files changed

+380
-20
lines changed

src/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,11 @@
384384
"default": "",
385385
"description": "%settings.customStoragePath.description%"
386386
},
387+
"roo-cline.persistChatInRemote": {
388+
"type": "boolean",
389+
"default": true,
390+
"description": "%settings.persistChatInRemote.description%"
391+
},
387392
"roo-cline.enableCodeActions": {
388393
"type": "boolean",
389394
"default": true,

src/package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"settings.vsCodeLmModelSelector.vendor.description": "The vendor of the language model (e.g. copilot)",
3737
"settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)",
3838
"settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\RooCodeStorage')",
39+
"settings.persistChatInRemote.description": "Persist chat history in Remote SSH sessions. When enabled, chat history will be saved per remote workspace and restored on reconnection.",
3940
"settings.enableCodeActions.description": "Enable Roo Code quick fixes",
4041
"settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import.",
4142
"settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)",
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import * as vscode from "vscode"
3+
import {
4+
isRemoteEnvironment,
5+
getRemoteType,
6+
getRemoteWorkspaceId,
7+
getEnvironmentStoragePath,
8+
} from "../remoteEnvironment"
9+
10+
// Mock vscode module
11+
vi.mock("vscode", () => ({
12+
env: {
13+
remoteName: undefined,
14+
},
15+
workspace: {
16+
workspaceFolders: undefined,
17+
},
18+
}))
19+
20+
describe("remoteEnvironment", () => {
21+
let mockVscode: any
22+
23+
beforeEach(() => {
24+
mockVscode = vi.mocked(vscode)
25+
})
26+
27+
afterEach(() => {
28+
// Reset mocks after each test
29+
mockVscode.env.remoteName = undefined
30+
mockVscode.workspace.workspaceFolders = undefined
31+
})
32+
33+
describe("isRemoteEnvironment", () => {
34+
it("should return false when not in remote environment", () => {
35+
mockVscode.env.remoteName = undefined
36+
expect(isRemoteEnvironment()).toBe(false)
37+
})
38+
39+
it("should return true when in SSH remote environment", () => {
40+
mockVscode.env.remoteName = "ssh-remote"
41+
expect(isRemoteEnvironment()).toBe(true)
42+
})
43+
44+
it("should return true when in WSL environment", () => {
45+
mockVscode.env.remoteName = "wsl"
46+
expect(isRemoteEnvironment()).toBe(true)
47+
})
48+
49+
it("should return true when in dev container environment", () => {
50+
mockVscode.env.remoteName = "dev-container"
51+
expect(isRemoteEnvironment()).toBe(true)
52+
})
53+
54+
it("should return true when in codespaces environment", () => {
55+
mockVscode.env.remoteName = "codespaces"
56+
expect(isRemoteEnvironment()).toBe(true)
57+
})
58+
})
59+
60+
describe("getRemoteType", () => {
61+
it("should return undefined when not in remote environment", () => {
62+
mockVscode.env.remoteName = undefined
63+
expect(getRemoteType()).toBeUndefined()
64+
})
65+
66+
it("should return 'ssh-remote' for SSH remote", () => {
67+
mockVscode.env.remoteName = "ssh-remote"
68+
expect(getRemoteType()).toBe("ssh-remote")
69+
})
70+
71+
it("should return 'wsl' for WSL", () => {
72+
mockVscode.env.remoteName = "wsl"
73+
expect(getRemoteType()).toBe("wsl")
74+
})
75+
})
76+
77+
describe("getRemoteWorkspaceId", () => {
78+
it("should return undefined when not in remote environment", () => {
79+
mockVscode.env.remoteName = undefined
80+
expect(getRemoteWorkspaceId()).toBeUndefined()
81+
})
82+
83+
it("should return remote name when no workspace folders", () => {
84+
mockVscode.env.remoteName = "ssh-remote"
85+
mockVscode.workspace.workspaceFolders = undefined
86+
expect(getRemoteWorkspaceId()).toBe("ssh-remote")
87+
})
88+
89+
it("should return remote name when workspace folders is empty", () => {
90+
mockVscode.env.remoteName = "ssh-remote"
91+
mockVscode.workspace.workspaceFolders = []
92+
expect(getRemoteWorkspaceId()).toBe("ssh-remote")
93+
})
94+
95+
it("should include workspace folder name and hash in ID", () => {
96+
mockVscode.env.remoteName = "ssh-remote"
97+
mockVscode.workspace.workspaceFolders = [
98+
{
99+
name: "my-project",
100+
uri: {
101+
toString: () => "file:///home/user/projects/my-project",
102+
},
103+
},
104+
]
105+
const id = getRemoteWorkspaceId()
106+
expect(id).toMatch(/^ssh-remote-my-project-[a-z0-9]+$/)
107+
})
108+
109+
it("should create consistent hash for same workspace", () => {
110+
mockVscode.env.remoteName = "ssh-remote"
111+
const workspaceUri = "file:///home/user/projects/my-project"
112+
mockVscode.workspace.workspaceFolders = [
113+
{
114+
name: "my-project",
115+
uri: {
116+
toString: () => workspaceUri,
117+
},
118+
},
119+
]
120+
const id1 = getRemoteWorkspaceId()
121+
const id2 = getRemoteWorkspaceId()
122+
expect(id1).toBe(id2)
123+
})
124+
125+
it("should create different hashes for different workspaces", () => {
126+
mockVscode.env.remoteName = "ssh-remote"
127+
128+
// First workspace
129+
mockVscode.workspace.workspaceFolders = [
130+
{
131+
name: "project1",
132+
uri: {
133+
toString: () => "file:///home/user/projects/project1",
134+
},
135+
},
136+
]
137+
const id1 = getRemoteWorkspaceId()
138+
139+
// Second workspace
140+
mockVscode.workspace.workspaceFolders = [
141+
{
142+
name: "project2",
143+
uri: {
144+
toString: () => "file:///home/user/projects/project2",
145+
},
146+
},
147+
]
148+
const id2 = getRemoteWorkspaceId()
149+
150+
expect(id1).not.toBe(id2)
151+
})
152+
})
153+
154+
describe("getEnvironmentStoragePath", () => {
155+
const basePath = "/home/user/.vscode/extensions/storage"
156+
157+
it("should return base path unchanged when not in remote environment", () => {
158+
mockVscode.env.remoteName = undefined
159+
expect(getEnvironmentStoragePath(basePath)).toBe(basePath)
160+
})
161+
162+
it("should add remote subdirectory for SSH remote", () => {
163+
mockVscode.env.remoteName = "ssh-remote"
164+
mockVscode.workspace.workspaceFolders = [
165+
{
166+
name: "my-project",
167+
uri: {
168+
toString: () => "file:///home/user/projects/my-project",
169+
},
170+
},
171+
]
172+
const result = getEnvironmentStoragePath(basePath)
173+
expect(result).toMatch(
174+
/^\/home\/user\/.vscode\/extensions\/storage\/remote\/ssh-remote-my-project-[a-z0-9]+$/,
175+
)
176+
})
177+
178+
it("should add remote subdirectory for WSL", () => {
179+
mockVscode.env.remoteName = "wsl"
180+
mockVscode.workspace.workspaceFolders = [
181+
{
182+
name: "linux-project",
183+
uri: {
184+
toString: () => "file:///home/user/linux-project",
185+
},
186+
},
187+
]
188+
const result = getEnvironmentStoragePath(basePath)
189+
expect(result).toMatch(/^\/home\/user\/.vscode\/extensions\/storage\/remote\/wsl-linux-project-[a-z0-9]+$/)
190+
})
191+
192+
it("should add remote subdirectory for dev containers", () => {
193+
mockVscode.env.remoteName = "dev-container"
194+
mockVscode.workspace.workspaceFolders = [
195+
{
196+
name: "container-app",
197+
uri: {
198+
toString: () => "file:///workspace/container-app",
199+
},
200+
},
201+
]
202+
const result = getEnvironmentStoragePath(basePath)
203+
expect(result).toMatch(
204+
/^\/home\/user\/.vscode\/extensions\/storage\/remote\/dev-container-container-app-[a-z0-9]+$/,
205+
)
206+
})
207+
208+
it("should handle Windows paths correctly", () => {
209+
const windowsBasePath = "C:\\Users\\user\\AppData\\Roaming\\Code\\User\\globalStorage\\roo-cline"
210+
mockVscode.env.remoteName = "ssh-remote"
211+
mockVscode.workspace.workspaceFolders = [
212+
{
213+
name: "remote-project",
214+
uri: {
215+
toString: () => "file:///home/remote/project",
216+
},
217+
},
218+
]
219+
const result = getEnvironmentStoragePath(windowsBasePath)
220+
// The path.join will use forward slashes even on Windows in Node.js context
221+
expect(result).toMatch(
222+
/^C:\\Users\\user\\AppData\\Roaming\\Code\\User\\globalStorage\\roo-cline\/remote\/ssh-remote-remote-project-[a-z0-9]+$/,
223+
)
224+
})
225+
226+
it("should fallback to base path when remote ID cannot be determined", () => {
227+
mockVscode.env.remoteName = "unknown-remote"
228+
mockVscode.workspace.workspaceFolders = undefined
229+
// In this case, getRemoteWorkspaceId returns just "unknown-remote"
230+
const result = getEnvironmentStoragePath(basePath)
231+
expect(result).toBe(`${basePath}/remote/unknown-remote`)
232+
})
233+
})
234+
})

src/utils/__tests__/storage.spec.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ vi.mock("fs/promises", async () => {
55
return (mod as any).default ?? mod
66
})
77

8+
// Mock the remoteEnvironment module
9+
vi.mock("../remoteEnvironment", () => ({
10+
isRemoteEnvironment: vi.fn(() => false),
11+
getEnvironmentStoragePath: vi.fn((path: string) => path),
12+
}))
13+
814
describe("getStorageBasePath - customStoragePath", () => {
915
const defaultPath = "/test/global-storage"
1016

@@ -63,9 +69,13 @@ describe("getStorageBasePath - customStoragePath", () => {
6369
const firstArg = showErrorSpy.mock.calls[0][0]
6470
expect(typeof firstArg).toBe("string")
6571
})
66-
it("returns the default path when customStoragePath is an empty string and does not touch fs", async () => {
72+
it("returns the default path when customStoragePath is an empty string", async () => {
6773
vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({
68-
get: vi.fn().mockReturnValue(""),
74+
get: vi.fn((key: string) => {
75+
if (key === "customStoragePath") return ""
76+
if (key === "persistChatInRemote") return true
77+
return undefined
78+
}),
6979
} as any)
7080

7181
const fsPromises = await import("fs/promises")
@@ -74,7 +84,9 @@ describe("getStorageBasePath - customStoragePath", () => {
7484
const result = await getStorageBasePath(defaultPath)
7585

7686
expect(result).toBe(defaultPath)
77-
expect((fsPromises as any).mkdir).not.toHaveBeenCalled()
87+
// Now it will create the directory for the final path
88+
expect((fsPromises as any).mkdir).toHaveBeenCalledWith(defaultPath, { recursive: true })
89+
// Access check is not called since we're using the default path
7890
expect((fsPromises as any).access).not.toHaveBeenCalled()
7991
})
8092

src/utils/remoteEnvironment.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import * as vscode from "vscode"
2+
3+
/**
4+
* Detects if the extension is running in a remote environment
5+
* (Remote SSH, WSL, Dev Containers, Codespaces, etc.)
6+
*/
7+
export function isRemoteEnvironment(): boolean {
8+
// Check if we're in a remote extension host
9+
// vscode.env.remoteName will be defined in remote contexts
10+
// It will be 'ssh-remote' for SSH, 'wsl' for WSL, 'dev-container' for containers, etc.
11+
return typeof vscode.env.remoteName !== "undefined"
12+
}
13+
14+
/**
15+
* Gets the type of remote environment if in one
16+
* @returns The remote name (e.g., 'ssh-remote', 'wsl', 'dev-container') or undefined if local
17+
*/
18+
export function getRemoteType(): string | undefined {
19+
return vscode.env.remoteName
20+
}
21+
22+
/**
23+
* Gets a unique identifier for the remote workspace
24+
* This combines the remote type with workspace information to create
25+
* a stable identifier for the remote session
26+
*/
27+
export function getRemoteWorkspaceId(): string | undefined {
28+
if (!isRemoteEnvironment()) {
29+
return undefined
30+
}
31+
32+
const remoteName = vscode.env.remoteName || "remote"
33+
const workspaceFolders = vscode.workspace.workspaceFolders
34+
35+
if (workspaceFolders && workspaceFolders.length > 0) {
36+
// Use the first workspace folder's name and URI as part of the identifier
37+
const firstFolder = workspaceFolders[0]
38+
const folderName = firstFolder.name
39+
// Create a stable hash from the URI to avoid issues with special characters
40+
const uriHash = hashString(firstFolder.uri.toString())
41+
return `${remoteName}-${folderName}-${uriHash}`
42+
}
43+
44+
// Fallback to just remote name if no workspace
45+
return remoteName
46+
}
47+
48+
/**
49+
* Simple string hash function for creating stable identifiers
50+
*/
51+
function hashString(str: string): string {
52+
let hash = 0
53+
for (let i = 0; i < str.length; i++) {
54+
const char = str.charCodeAt(i)
55+
hash = (hash << 5) - hash + char
56+
hash = hash & hash // Convert to 32-bit integer
57+
}
58+
return Math.abs(hash).toString(36)
59+
}
60+
61+
/**
62+
* Gets the appropriate storage base path for the current environment
63+
* In remote environments, this will include a remote-specific subdirectory
64+
* to keep remote and local chat histories separate
65+
*/
66+
export function getEnvironmentStoragePath(basePath: string): string {
67+
if (!isRemoteEnvironment()) {
68+
// Local environment - use the base path as-is
69+
return basePath
70+
}
71+
72+
// Remote environment - add a remote-specific subdirectory
73+
const remoteId = getRemoteWorkspaceId()
74+
if (remoteId) {
75+
// Use path.join for proper path construction
76+
const path = require("path")
77+
return path.join(basePath, "remote", remoteId)
78+
}
79+
80+
// Fallback to base path if we can't determine remote ID
81+
return basePath
82+
}

0 commit comments

Comments
 (0)