add WorkspaceRef + remote identity on RecentProject (T-332)

The model-independent half of the ssh:// open scheme. WorkspaceRef is
the value type for "where a workspace lives" — a local path or
ssh://[user@]host[:port]/abs/path, with parse/uri round-tripping and a
host:path display form. RecentProject carries host/port/user
(back-compatible JSON: absent keys deserialize as local) so remote
recents survive restarts and render with their host badge.

The remaining T-332 scope — ProjectManager.current off bare Directory,
open() branching, remote resolveProject — is gated on the execution
layer (T-336), which is itself blocked on the T-330 footprint pick;
the epic's blocker graph now encodes that gating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 03:11:57 +02:00
co-authored by Claude Fable 5
parent be28bdef82
commit 6817abaf96
6 changed files with 181 additions and 1 deletions
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
/// Unit tests for `WorkspaceRef` (T-332) — local/remote workspace
/// identity and the `ssh://[user@]host[:port]/abs/path` open scheme.
library;
import 'package:clide/kernel/kernel.dart';
import 'package:test/test.dart';
void main() {
group('WorkspaceRef.parse — local', () {
test('a bare path is a local ref', () {
final ref = WorkspaceRef.parse('/var/repo');
expect(ref, const WorkspaceRef.local('/var/repo'));
expect(ref!.isRemote, isFalse);
expect(ref.uri, '/var/repo');
expect(ref.display, '/var/repo');
});
test('a relative path stays a local ref verbatim', () {
expect(WorkspaceRef.parse('repo'), const WorkspaceRef.local('repo'));
});
});
group('WorkspaceRef.parse — ssh://', () {
test('host + path', () {
final ref = WorkspaceRef.parse('ssh://buildbox/srv/repo');
expect(ref, WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo'));
expect(ref!.isRemote, isTrue);
expect(ref.port, isNull);
expect(ref.user, isNull);
});
test('user@host:port + path', () {
final ref = WorkspaceRef.parse('ssh://jeroen@buildbox:2222/srv/repo');
expect(ref!.user, 'jeroen');
expect(ref.host, 'buildbox');
expect(ref.port, 2222);
expect(ref.path, '/srv/repo');
});
test('uri round-trips through parse', () {
const refs = [
WorkspaceRef.local('/var/repo'),
WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo'),
WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo', port: 2222, user: 'jeroen'),
];
for (final ref in refs) {
expect(WorkspaceRef.parse(ref.uri), ref, reason: ref.uri);
}
});
test('display is host:path', () {
expect(WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo').display, 'buildbox:/srv/repo');
});
test('missing host or missing path is rejected', () {
expect(WorkspaceRef.parse('ssh:///srv/repo'), isNull);
expect(WorkspaceRef.parse('ssh://buildbox'), isNull);
expect(WorkspaceRef.parse('ssh://buildbox/'), isNull);
});
test('garbage after the scheme is rejected, not crashed on', () {
expect(WorkspaceRef.parse('ssh://[::bad'), isNull);
});
});
group('WorkspaceRef equality', () {
test('value equality + hashCode', () {
expect(WorkspaceRef.remote(host: 'h', path: '/p'), WorkspaceRef.remote(host: 'h', path: '/p'));
expect(WorkspaceRef.remote(host: 'h', path: '/p').hashCode, WorkspaceRef.remote(host: 'h', path: '/p').hashCode);
expect(WorkspaceRef.remote(host: 'h', path: '/p'), isNot(const WorkspaceRef.local('/p')));
expect(WorkspaceRef.remote(host: 'h', path: '/p', port: 22), isNot(WorkspaceRef.remote(host: 'h', path: '/p')));
});
test('toString carries the uri form', () {
expect(WorkspaceRef.remote(host: 'h', path: '/p').toString(), contains('ssh://h/p'));
});
});
}