Skip to content

Commit e42ee92

Browse files
committed
fix: tighten remote SSH launch semantics
1 parent 6c10454 commit e42ee92

3 files changed

Lines changed: 175 additions & 9 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ vscli open /path/to/project # open vscode in the specified directory
244244

245245
The default behavior tries to detect whether the project is a [dev container](https://containers.dev/) project. If it is, it will launch the dev container instead - if not it will launch vscode normally.
246246

247+
These behaviors apply to local workspaces. Remote SSH workspaces always open as remote folders; `--behavior detect` and `--behavior force-container` are not supported with `--remote-host`.
248+
247249
You can change the launch behavior using the `--behavior` flag:
248250

249251
```sh
@@ -281,6 +283,7 @@ vscli recent --command cursor # open the selected project with
281283
vscli recent --behavior force-container # force open the selected project in a dev container
282284
vscli recent --command cursor --behavior detect # open with cursor and detect if dev container should be used
283285
vscli recent --config .devcontainer/custom.json # open with a specific dev container config
286+
vscli recent --remote-host my-ec2 # reopen the selected project on a remembered remote host
284287
vscli recent -- --disable-gpu # pass additional arguments to the editor
285288
vscli recent --hide-instructions # hide the keybinding instructions from the UI
286289
vscli recent --hide-info # hide additional information like strategy, command, args and dev container path
@@ -305,11 +308,12 @@ If you already use VS Code Remote SSH, you can point `vscli` at a remote host al
305308

306309
```sh
307310
vscli open --remote-host my-ec2 /home/ec2-user/app
308-
vscli open --remote-host my-ec2 --behavior force-container /home/ec2-user/app
309311
vscli recent --remote-host my-ec2
310312
```
311313

312-
This opens the workspace using a `vscode-remote://ssh-remote+...` folder URI. If the remote folder contains a `.devcontainer` setup, VS Code Dev Containers can reopen it in a container on that remote host.
314+
This opens the workspace using a `vscode-remote://ssh-remote+...` folder URI and stores the remote host in `recent` history so you can reopen it from the UI later.
315+
316+
`vscli` does not manage dev containers on remote SSH hosts. Remote workspaces are opened as SSH folders; if the remote folder contains a `.devcontainer` setup, VS Code Dev Containers may offer to reopen it in a container afterward.
313317

314318
#### Environment Variables
315319

src/history.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,59 @@ impl Tracker {
229229
Ok(())
230230
}
231231
}
232+
233+
#[cfg(test)]
234+
mod tests {
235+
use super::{Entry, History, Tracker};
236+
use crate::launch::{Behavior, ContainerStrategy};
237+
use chrono::Utc;
238+
use std::ffi::OsString;
239+
use std::path::PathBuf;
240+
241+
fn unique_test_path(name: &str) -> PathBuf {
242+
let unique = format!(
243+
"vscli-history-{name}-{}-{}",
244+
std::process::id(),
245+
std::time::SystemTime::now()
246+
.duration_since(std::time::UNIX_EPOCH)
247+
.unwrap()
248+
.as_nanos()
249+
);
250+
std::env::temp_dir().join(unique).join("history.json")
251+
}
252+
253+
#[test]
254+
fn tracker_store_and_load_preserve_remote_host_entries() {
255+
let path = unique_test_path("remote-host");
256+
let mut tracker = Tracker {
257+
path: path.clone(),
258+
history: History::default(),
259+
};
260+
261+
tracker.history.upsert(Entry {
262+
workspace_name: "workspace".to_string(),
263+
dev_container_name: None,
264+
config_name: None,
265+
workspace_path: PathBuf::from("/home/dev/workspace"),
266+
remote_host: Some("vscli-remote-test".to_string()),
267+
config_path: None,
268+
behavior: Behavior {
269+
strategy: ContainerStrategy::ForceClassic,
270+
args: vec![OsString::from("--reuse-window")],
271+
command: "code".to_string(),
272+
},
273+
last_opened: Utc::now(),
274+
});
275+
276+
tracker.store().unwrap();
277+
278+
let loaded = Tracker::load(path).unwrap();
279+
let entries = loaded.history.into_entries();
280+
assert_eq!(entries.len(), 1);
281+
assert_eq!(entries[0].remote_host.as_deref(), Some("vscli-remote-test"));
282+
assert_eq!(
283+
entries[0].behavior.strategy,
284+
ContainerStrategy::ForceClassic
285+
);
286+
}
287+
}

src/main.rs

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use crate::config_store::ConfigStore;
2727
use crate::history::{Entry, Tracker};
2828

2929
use crate::{
30-
launch::{Behavior, Setup},
30+
launch::{Behavior, ContainerStrategy, Setup},
3131
opts::{LaunchArgs, Opts},
3232
workspace::Workspace,
3333
};
@@ -69,12 +69,35 @@ fn workspace_root_from_config(
6969
current = parent;
7070
};
7171
let path_abs = std::fs::canonicalize(path_arg).unwrap_or(path_arg.to_path_buf());
72-
let sub = if path_abs.starts_with(&root) && path_abs != root {
73-
path_abs.strip_prefix(&root).ok().map(Path::to_path_buf)
72+
if path_abs.starts_with(&root) {
73+
let sub = if path_abs == root {
74+
None
75+
} else {
76+
path_abs.strip_prefix(&root).ok().map(Path::to_path_buf)
77+
};
78+
Ok((root, sub))
7479
} else {
75-
None
76-
};
77-
Ok((root, sub))
80+
Ok((path_abs, None))
81+
}
82+
}
83+
84+
fn resolve_strategy_for_remote(
85+
remote_host: Option<&str>,
86+
strategy: Option<ContainerStrategy>,
87+
) -> Result<ContainerStrategy> {
88+
if remote_host.is_some() {
89+
match strategy {
90+
None | Some(ContainerStrategy::ForceClassic) => Ok(ContainerStrategy::ForceClassic),
91+
Some(ContainerStrategy::Detect) => {
92+
bail!("--behavior detect is not supported with --remote-host.")
93+
}
94+
Some(ContainerStrategy::ForceContainer) => {
95+
bail!("--behavior force-container is not supported with --remote-host.")
96+
}
97+
}
98+
} else {
99+
Ok(strategy.unwrap_or_default())
100+
}
78101
}
79102

80103
fn open_workspace(
@@ -111,7 +134,7 @@ fn open_workspace(
111134
let remote_host = ws.remote_host.clone();
112135

113136
let behavior = Behavior {
114-
strategy: launch.behavior.unwrap_or_default(),
137+
strategy: resolve_strategy_for_remote(ws.remote_host.as_deref(), launch.behavior)?,
115138
args: launch.args,
116139
command: launch.command.unwrap_or_else(|| "code".to_string()),
117140
};
@@ -167,6 +190,9 @@ fn reopen_recent(
167190
entry.behavior.args = launch.args;
168191
}
169192

193+
entry.behavior.strategy =
194+
resolve_strategy_for_remote(remote_host.as_deref(), Some(entry.behavior.strategy))?;
195+
170196
let resolved_config = if launch.config.is_some() {
171197
resolve_launch_config(launch.config.as_ref(), config_store)?
172198
} else {
@@ -279,3 +305,83 @@ fn log_format(
279305
writeln!(buf, "{}: {}", colored_level, record.args())
280306
}
281307
}
308+
309+
#[cfg(test)]
310+
mod tests {
311+
use super::{resolve_strategy_for_remote, workspace_root_from_config};
312+
use crate::launch::ContainerStrategy;
313+
use std::path::{Path, PathBuf};
314+
315+
fn unique_test_dir(name: &str) -> PathBuf {
316+
let unique = format!(
317+
"vscli-main-{name}-{}-{}",
318+
std::process::id(),
319+
std::time::SystemTime::now()
320+
.duration_since(std::time::UNIX_EPOCH)
321+
.unwrap()
322+
.as_nanos()
323+
);
324+
std::env::temp_dir().join(unique)
325+
}
326+
327+
#[test]
328+
fn preserves_project_path_for_external_config() {
329+
let root = unique_test_dir("external-config");
330+
let config = root
331+
.join("configs")
332+
.join("rust-dev")
333+
.join(".devcontainer")
334+
.join("devcontainer.json");
335+
let project = root.join("projects").join("my-app");
336+
337+
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
338+
std::fs::create_dir_all(&project).unwrap();
339+
std::fs::write(&config, "{}\n").unwrap();
340+
341+
let (workspace, subfolder) = workspace_root_from_config(&config, &project).unwrap();
342+
343+
assert_eq!(workspace, project.canonicalize().unwrap());
344+
assert_eq!(subfolder, None);
345+
}
346+
347+
#[test]
348+
fn derives_subfolder_when_path_is_inside_config_workspace() {
349+
let root = unique_test_dir("subfolder");
350+
let workspace = root.join("workspace");
351+
let config = workspace.join(".devcontainer").join("devcontainer.json");
352+
let project = workspace.join("packages").join("api");
353+
354+
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
355+
std::fs::create_dir_all(&project).unwrap();
356+
std::fs::write(&config, "{}\n").unwrap();
357+
358+
let (resolved_workspace, subfolder) =
359+
workspace_root_from_config(&config, &project).unwrap();
360+
361+
assert_eq!(resolved_workspace, workspace.canonicalize().unwrap());
362+
assert_eq!(subfolder.as_deref(), Some(Path::new("packages/api")));
363+
}
364+
365+
#[test]
366+
fn remote_workspaces_default_to_force_classic() {
367+
let strategy = resolve_strategy_for_remote(Some("remote-test"), None).unwrap();
368+
assert_eq!(strategy, ContainerStrategy::ForceClassic);
369+
}
370+
371+
#[test]
372+
fn remote_workspaces_reject_detect_behavior() {
373+
let err = resolve_strategy_for_remote(Some("remote-test"), Some(ContainerStrategy::Detect))
374+
.unwrap_err();
375+
assert!(err.to_string().contains("not supported with --remote-host"));
376+
}
377+
378+
#[test]
379+
fn remote_workspaces_reject_force_container_behavior() {
380+
let err = resolve_strategy_for_remote(
381+
Some("remote-test"),
382+
Some(ContainerStrategy::ForceContainer),
383+
)
384+
.unwrap_err();
385+
assert!(err.to_string().contains("not supported with --remote-host"));
386+
}
387+
}

0 commit comments

Comments
 (0)