fix(projects): context-aware Add Project sheet on remote servers (#54)

Pre-fix `AddProjectSheet` always rendered a Browse button backed by
NSOpenPanel — a Mac-local Finder dialog. On a remote SSH server
context, users would pick a Mac path (`/Users/alan/code/...`), the
path would land in the projects registry as the project's "remote"
working directory, and tool calls would fail at runtime because
that path doesn't exist on the Linux server.

Tier-1 fix:
- Pass active ServerContext into AddProjectSheet (was context-blind).
- Local context: Browse button unchanged. Pixel-identical to today.
- Remote context: hide Browse, surface a hint "Path on <server> —
  must already exist on the server", add a Verify button that runs
  context.makeTransport().stat(path) over the existing SSH transport
  and renders inline:
    spinner    → checking
    green ✓    → directory exists
    yellow ⚠   → missing / file-not-dir / unreadable
- Path field's onChange resets stale verification so users don't see
  a green check for a path they've since edited.

Tier 2 (full remote SFTP-backed picker that lets users navigate the
remote filesystem) is deferred — separate larger feature, ~200-300
lines and its own UX. Tier 1 unblocks remote project creation now,
which was the blocking bug.

Other 5 NSOpenPanel call sites audited — `TemplateInstallSheet:423`
likely has the same class of bug for template install destinations
on remote contexts; flagged in the issue body for a follow-up. The
other 4 (template-file picker, key-file picker, etc.) all pick
Mac-local artifacts and are correct as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 13:59:10 +02:00
parent c312a565b6
commit 0bfae1227a
@@ -306,7 +306,7 @@ struct ProjectsView: View {
onAddProject: { showingAddSheet = true }
)
.sheet(isPresented: $showingAddSheet) {
AddProjectSheet { name, path in
AddProjectSheet(context: serverContext) { name, path in
viewModel.addProject(name: name, path: path)
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
@@ -593,28 +593,38 @@ struct AddProjectSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var projectName = ""
@State private var projectPath = ""
/// Inline verification result for remote contexts (issue #54).
/// Renders alongside the path field as a green check / red x so
/// users learn whether a remote path is valid BEFORE they hit Add
/// and the agent's tool calls fail at runtime.
@State private var remoteVerification: RemoteVerification = .idle
/// Active server context. On remote contexts the local Browse
/// button is hidden (NSOpenPanel browses the Mac filesystem,
/// useless when the project lives on a remote host) and replaced
/// with a Verify button driven by the SSH transport's `stat`.
let context: ServerContext
let onAdd: (String, String) -> Void
private enum RemoteVerification: Equatable {
case idle
case verifying
case ok(String) // green: "Directory exists (1.2k items)" etc.
case warn(String) // red: missing / not a dir / unreadable
}
var body: some View {
VStack(spacing: 16) {
Text("Add Project")
.font(.headline)
TextField("Project Name", text: $projectName)
.textFieldStyle(.roundedBorder)
HStack {
TextField("Project Path", text: $projectPath)
.textFieldStyle(.roundedBorder)
Button("Browse...") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
projectPath = url.path
if projectName.isEmpty {
projectName = url.lastPathComponent
}
}
VStack(alignment: .leading, spacing: 6) {
pathInputRow
if context.isRemote {
Text("Path on \(context.displayName) — must already exist on the server. Tool calls run with this directory as their working directory.")
.font(.caption)
.foregroundStyle(.secondary)
verificationBadge
}
}
HStack {
@@ -631,6 +641,102 @@ struct AddProjectSheet: View {
}
}
.padding()
.frame(width: 400)
.frame(width: 440)
}
@ViewBuilder
private var pathInputRow: some View {
HStack {
TextField("Project Path", text: $projectPath)
.textFieldStyle(.roundedBorder)
.onChange(of: projectPath) { _, _ in
// Stale verification once the path edits reset to
// idle so users don't see a green check for a path
// they've since changed.
if remoteVerification != .idle {
remoteVerification = .idle
}
}
if context.isRemote {
Button("Verify") {
Task { await verifyRemotePath() }
}
.disabled(projectPath.isEmpty || remoteVerification == .verifying)
} else {
Button("Browse...") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
projectPath = url.path
if projectName.isEmpty {
projectName = url.lastPathComponent
}
}
}
}
}
}
@ViewBuilder
private var verificationBadge: some View {
switch remoteVerification {
case .idle:
EmptyView()
case .verifying:
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Checking on \(context.displayName)")
.font(.caption)
.foregroundStyle(.secondary)
}
case .ok(let detail):
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ScarfColor.success)
Text(detail)
.font(.caption)
.foregroundStyle(.primary)
}
case .warn(let detail):
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
Text(detail)
.font(.caption)
.foregroundStyle(.primary)
}
}
}
/// Verify the entered path on the remote via the existing SSH
/// transport. Uses `stat` (not just `fileExists`) so we can reject
/// files-that-aren't-dirs without a separate round trip.
private func verifyRemotePath() async {
let path = projectPath.trimmingCharacters(in: .whitespaces)
guard !path.isEmpty, context.isRemote else { return }
remoteVerification = .verifying
let snapshot = context
let result: RemoteVerification = await Task.detached {
let transport = snapshot.makeTransport()
guard transport.fileExists(path) else {
return .warn("Path doesn't exist on \(snapshot.displayName).")
}
guard let stat = transport.stat(path) else {
// Stat failed even though `test -e` passed typically
// a permission issue on the parent dir. Surface as a
// warning so the user knows the path is reachable but
// not introspectable.
return .warn("Found, but couldn't stat — check parent directory permissions.")
}
if stat.isDirectory {
return .ok("Directory exists on \(snapshot.displayName).")
} else {
return .warn("Path is a file, not a directory. Project paths must be directories.")
}
}.value
remoteVerification = result
}
}