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:
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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;
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
Binary file not shown.
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user