From 6817abaf96f33abb840ec2f848ea50bf6e6b3c51 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 03:11:57 +0200 Subject: [PATCH] add WorkspaceRef + remote identity on RecentProject (T-332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .pql/changelog/ticket_deps/2026-06.sql | 5 ++ lib/kernel/kernel.dart | 1 + lib/kernel/src/project.dart | 34 ++++++++++- lib/kernel/src/workspace_ref.dart | 64 +++++++++++++++++++ test/kernel/src/project_test.dart | Bin 12756 -> 14371 bytes test/kernel/src/workspace_ref_test.dart | 78 ++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 lib/kernel/src/workspace_ref.dart create mode 100644 test/kernel/src/workspace_ref_test.dart diff --git a/.pql/changelog/ticket_deps/2026-06.sql b/.pql/changelog/ticket_deps/2026-06.sql index 4e6a9628..7fe6ee97 100644 --- a/.pql/changelog/ticket_deps/2026-06.sql +++ b/.pql/changelog/ticket_deps/2026-06.sql @@ -26,3 +26,8 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:09', '2026-06-10 13:27:09', NULL, '1d5185ae9c9676bd70d0e02e3a5e79a1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '90dca94aa700c143290b2b1afaca09ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '9b9081edc77f0e9a4b689bbc191771db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', '06FB3DQEMTDHF8SV27AKAB8JHW', '2026-06-10 13:27:05', '2026-06-12 01:11:12', NULL, '17f1c884268a172f803f407a2ad47c8c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DN94MBCTYJW17ZCYVSXE0', '2026-06-10 13:27:09', '2026-06-12 01:11:17', NULL, 'a67368ff15089dce57838840632fe07e', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-12 01:11:22', NULL, '0ec8ff6c455136e45fbb1d06a2a690f1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-12 01:11:26', NULL, '3c985828c591c88d492eb261c6d26d33', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DMF20SYFDT6WX2RFBQXKW', '2026-06-12 01:11:31', '2026-06-12 01:11:31', NULL, '81fd318a43138a3bef82e31f928ff0b2', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index ba2a0a90..261cf017 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -65,3 +65,4 @@ export 'src/theme/semantic.dart'; export 'src/theme/tokens.dart'; export 'src/toolchain.dart'; export 'src/window_controls.dart'; +export 'src/workspace_ref.dart'; diff --git a/lib/kernel/src/project.dart b/lib/kernel/src/project.dart index bfab2320..2bba1ad4 100644 --- a/lib/kernel/src/project.dart +++ b/lib/kernel/src/project.dart @@ -6,10 +6,20 @@ import 'package:clide/kernel/src/events/types.dart'; import 'package:clide/kernel/src/log.dart'; import 'package:clide/kernel/src/settings.dart'; import 'package:clide/kernel/src/toolchain.dart'; +import 'package:clide/kernel/src/workspace_ref.dart'; import 'package:flutter/foundation.dart'; class RecentProject { - const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened, this.startupSticky = false}); + const RecentProject({ + required this.path, + required this.name, + this.branch, + required this.lastOpened, + this.startupSticky = false, + this.host, + this.port, + this.user, + }); final String path; final String name; @@ -21,12 +31,27 @@ class RecentProject { /// opens it directly; otherwise the welcome screen takes over (T-115). final bool startupSticky; + /// Remote workspace identity (T-332/T-329): the SSH host (or + /// `~/.ssh/config` alias) the repo lives on. Absent = local — older + /// persisted recents deserialize as local automatically. + final String? host; + final int? port; + final String? user; + + bool get isRemote => host != null; + + /// This recent's location as a [WorkspaceRef]. + WorkspaceRef get ref => host == null ? WorkspaceRef.local(path) : WorkspaceRef.remote(host: host!, path: path, port: port, user: user); + RecentProject copyWith({bool? startupSticky, DateTime? lastOpened, String? branch}) => RecentProject( path: path, name: name, branch: branch ?? this.branch, lastOpened: lastOpened ?? this.lastOpened, startupSticky: startupSticky ?? this.startupSticky, + host: host, + port: port, + user: user, ); Map toJson() => { @@ -35,6 +60,9 @@ class RecentProject { 'branch': branch, 'lastOpened': lastOpened.toIso8601String(), if (startupSticky) 'startupSticky': true, + if (host != null) 'host': host, + if (port != null) 'port': port, + if (user != null) 'user': user, }; factory RecentProject.fromJson(Map json) => RecentProject( @@ -43,9 +71,13 @@ class RecentProject { branch: json['branch'] as String?, lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(), startupSticky: json['startupSticky'] as bool? ?? false, + host: json['host'] as String?, + port: json['port'] as int?, + user: json['user'] as String?, ); String get relativePath { + if (isRemote) return '$host:$path'; final home = Platform.environment['HOME'] ?? ''; if (home.isNotEmpty && path.startsWith(home)) return '~${path.substring(home.length)}'; return path; diff --git a/lib/kernel/src/workspace_ref.dart b/lib/kernel/src/workspace_ref.dart new file mode 100644 index 00000000..cb38fade --- /dev/null +++ b/lib/kernel/src/workspace_ref.dart @@ -0,0 +1,64 @@ +/// WorkspaceRef (T-332): where a workspace lives — a local repo root or +/// a repo on a remote host reached over SSH (T-329). +/// +/// The remote form is written `ssh://[user@]host[:port]/abs/remote/path` +/// (host may be a `~/.ssh/config` alias — resolution happens at connect +/// time, not here). A bare string with no scheme is a local path. +library; + +/// A reference to a workspace root. Immutable value type. +class WorkspaceRef { + const WorkspaceRef.local(this.path) : host = null, port = null, user = null; + + const WorkspaceRef.remote({required String this.host, required this.path, this.port, this.user}); + + /// Remote host (or `~/.ssh/config` alias). Null means local. + final String? host; + + /// SSH port; null means the ssh default / config-resolved port. + final int? port; + + /// SSH user; null means the local username / config-resolved user. + final String? user; + + /// Absolute workspace path — on [host] when remote, locally otherwise. + final String path; + + bool get isRemote => host != null; + + /// Parse either a plain local path or an `ssh://` URI. Returns null + /// for a malformed `ssh://` form (no host, or no absolute path). + static WorkspaceRef? parse(String input) { + if (!input.startsWith('ssh://')) return WorkspaceRef.local(input); + final Uri uri; + try { + uri = Uri.parse(input); + } on FormatException { + return null; + } + if (uri.host.isEmpty || uri.path.isEmpty || uri.path == '/') return null; + return WorkspaceRef.remote(host: uri.host, path: uri.path, port: uri.hasPort ? uri.port : null, user: uri.userInfo.isEmpty ? null : uri.userInfo); + } + + /// The canonical string form: the bare path locally, the full + /// `ssh://` URI remotely. `parse(uri) == ref` round-trips. + String get uri { + if (!isRemote) return path; + final auth = user == null ? host! : '$user@$host'; + final p = port == null ? '' : ':$port'; + return 'ssh://$auth$p$path'; + } + + /// Compact human form for recents/switcher rows: `host:path` remotely + /// (e.g. `buildbox:/srv/repo`), the bare path locally. + String get display => isRemote ? '$host:$path' : path; + + @override + bool operator ==(Object other) => other is WorkspaceRef && other.host == host && other.port == port && other.user == user && other.path == path; + + @override + int get hashCode => Object.hash(host, port, user, path); + + @override + String toString() => 'WorkspaceRef($uri)'; +} diff --git a/test/kernel/src/project_test.dart b/test/kernel/src/project_test.dart index 654069aa0ebc793e1ee108a7874f893b9d5f801f..b80511437ee7d9468a68ee30c34fca437d309a2d 100644 GIT binary patch delta 1134 zcmaJ=%}x|S5Kc58F3UxYC>JVGGqZ7i5E2iI9Pr>l6JwBsgL-IYYMEwxr#sy}u&a^v z5ls38G~Nw~XA_P-fbZaI*xdue@}qs4>8`4;zOTOeoqZFB?e@CF0oMo;Gb}YoUX^*Q zB^3$^u5>E6)M=$q0x5G)nv9f<^(o*Hf4oPM30t)!LH&Z ztbs^0e{Mg+<{5|PU}<{Z-n|JNf#6*<(Suo@jmIu`BHjaTldPY(_{r!?gB*#-leqNR z@v=4k!OOv1Z@8Jy@tv_pme`A6i_5+eB*P8tCAG$(Yw4VbL>KlZwTh<_1^>UUO^<16 zhexyi2sGc>VW?nGDGlXYt|6CPfG*IF3}|kRK=tVCM0}eW7o#nzi_rQ&>Po!!6}zC_ zfG&~G9A3NilJU$A?ATvc9ZDE|Z}InSAztYkw2#(T&S5!Dgw&~_!;UjP5JWq-t(oym z+uWYLx#*qNP`uzBb2PUwZXA+iywrr2>III%+8frTR6Qe1o!ZOlv<^aI?9B}VrJ_n> zenOxi3QB%{H|q8^5XO>Y-D;omD=atPmloD6xRv`uU^x_lo1d_Q3raFT_a}mn=V@@3 zpBm+8?rz6jc%Rgz&bY-36}qCG&Z<-WsYFIJ-NRQF4^+YT>wWGzeCTm`vc&aVR0JjN z*(E=)Ix|LuLS^nvJ()=hKEQMsm`_u&`8u86xVYEm&lJozYu>%@GrTkFR(`bGzaIPr DU*U3d delta 12 TcmZ2na3y&I56|Xkp%bbAB{c;g diff --git a/test/kernel/src/workspace_ref_test.dart b/test/kernel/src/workspace_ref_test.dart new file mode 100644 index 00000000..9b83c828 --- /dev/null +++ b/test/kernel/src/workspace_ref_test.dart @@ -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')); + }); + }); +}