mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Add phase 7 remote read-only preview flow
This commit is contained in:
parent
5852207f81
commit
2eb2b43d2d
9 changed files with 867 additions and 31 deletions
|
|
@ -361,7 +361,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 575;
|
||||
CURRENT_PROJECT_VERSION = 577;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
@ -444,7 +444,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 575;
|
||||
CURRENT_PROJECT_VERSION = 577;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ struct NeonVisionMacAppCommands: Commands {
|
|||
activeEditorViewModel().selectedTab != nil
|
||||
}
|
||||
|
||||
private var hasSavableSelectedTab: Bool {
|
||||
guard let selectedTab = activeEditorViewModel().selectedTab else { return false }
|
||||
return !selectedTab.isReadOnlyPreview
|
||||
}
|
||||
|
||||
private func post(_ name: Notification.Name, object: Any? = nil) {
|
||||
postWindowCommand(name, object)
|
||||
}
|
||||
|
|
@ -137,7 +142,7 @@ struct NeonVisionMacAppCommands: Commands {
|
|||
}
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
.disabled(!hasSelectedTab)
|
||||
.disabled(!hasSavableSelectedTab)
|
||||
|
||||
Button("Save As…") {
|
||||
let current = activeEditorViewModel()
|
||||
|
|
@ -146,7 +151,7 @@ struct NeonVisionMacAppCommands: Commands {
|
|||
}
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
.disabled(!hasSelectedTab)
|
||||
.disabled(!hasSavableSelectedTab)
|
||||
|
||||
Button("Rename") {
|
||||
let current = activeEditorViewModel()
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import Observation
|
|||
|
||||
private final class RemoteSessionCompletionGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didComplete = false
|
||||
nonisolated(unsafe) private var didComplete = false
|
||||
|
||||
func claim() -> Bool {
|
||||
nonisolated func claim() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !didComplete else { return false }
|
||||
|
|
@ -32,6 +32,8 @@ final class RemoteSessionStore {
|
|||
var host: String
|
||||
var username: String
|
||||
var port: Int
|
||||
var sshKeyBookmarkData: Data?
|
||||
var sshKeyDisplayName: String
|
||||
var lastPreparedAt: Date
|
||||
|
||||
init(
|
||||
|
|
@ -40,6 +42,8 @@ final class RemoteSessionStore {
|
|||
host: String,
|
||||
username: String,
|
||||
port: Int,
|
||||
sshKeyBookmarkData: Data? = nil,
|
||||
sshKeyDisplayName: String = "",
|
||||
lastPreparedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
|
|
@ -47,6 +51,8 @@ final class RemoteSessionStore {
|
|||
self.host = host
|
||||
self.username = username
|
||||
self.port = port
|
||||
self.sshKeyBookmarkData = sshKeyBookmarkData
|
||||
self.sshKeyDisplayName = sshKeyDisplayName
|
||||
self.lastPreparedAt = lastPreparedAt
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +68,20 @@ final class RemoteSessionStore {
|
|||
}
|
||||
}
|
||||
|
||||
struct RemoteFileEntry: Identifiable, Equatable {
|
||||
let name: String
|
||||
let path: String
|
||||
let isDirectory: Bool
|
||||
|
||||
var id: String { path }
|
||||
}
|
||||
|
||||
struct RemotePreviewDocument: Equatable {
|
||||
let name: String
|
||||
let path: String
|
||||
let content: String
|
||||
}
|
||||
|
||||
static let shared = RemoteSessionStore()
|
||||
|
||||
private static let savedTargetsKey = "RemoteSessionSavedTargetsV1"
|
||||
|
|
@ -74,7 +94,14 @@ final class RemoteSessionStore {
|
|||
private(set) var runtimeState: RuntimeState = .idle
|
||||
private(set) var sessionStartedAt: Date? = nil
|
||||
private(set) var sessionStatusDetail: String = ""
|
||||
private(set) var remoteBrowserEntries: [RemoteFileEntry] = []
|
||||
private(set) var remoteBrowserPath: String = "~"
|
||||
private(set) var remoteBrowserStatusDetail: String = ""
|
||||
private(set) var isRemoteBrowserLoading: Bool = false
|
||||
private var liveConnection: NWConnection? = nil
|
||||
#if os(macOS)
|
||||
private var liveSSHProcess: Process? = nil
|
||||
#endif
|
||||
private let connectionQueue = DispatchQueue(label: "RemoteSessionStore.Connection")
|
||||
|
||||
private init() {
|
||||
|
|
@ -98,13 +125,21 @@ final class RemoteSessionStore {
|
|||
runtimeState == .connecting
|
||||
}
|
||||
|
||||
func connectPreview(nickname: String, host: String, username: String, port: Int) -> SavedTarget? {
|
||||
func connectPreview(
|
||||
nickname: String,
|
||||
host: String,
|
||||
username: String,
|
||||
port: Int,
|
||||
sshKeyBookmarkData: Data? = nil,
|
||||
sshKeyDisplayName: String = ""
|
||||
) -> SavedTarget? {
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedHost.isEmpty else { return nil }
|
||||
let sanitizedPort = min(max(port, 1), 65535)
|
||||
let normalizedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let displayNickname = normalizedNickname.isEmpty ? trimmedHost : normalizedNickname
|
||||
let normalizedSSHKeyDisplayName = sshKeyDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let target = SavedTarget(
|
||||
id: existingTargetID(host: trimmedHost, username: normalizedUsername, port: sanitizedPort) ?? UUID(),
|
||||
|
|
@ -112,6 +147,8 @@ final class RemoteSessionStore {
|
|||
host: trimmedHost,
|
||||
username: normalizedUsername,
|
||||
port: sanitizedPort,
|
||||
sshKeyBookmarkData: sshKeyBookmarkData,
|
||||
sshKeyDisplayName: normalizedSSHKeyDisplayName,
|
||||
lastPreparedAt: Date()
|
||||
)
|
||||
|
||||
|
|
@ -121,6 +158,7 @@ final class RemoteSessionStore {
|
|||
runtimeState = .ready
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = ""
|
||||
clearRemoteBrowserState()
|
||||
persist()
|
||||
syncLegacyDefaults(with: target)
|
||||
return target
|
||||
|
|
@ -133,6 +171,7 @@ final class RemoteSessionStore {
|
|||
runtimeState = .idle
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = ""
|
||||
clearRemoteBrowserState()
|
||||
persist()
|
||||
syncLegacyDefaultsForDisconnect()
|
||||
}
|
||||
|
|
@ -146,6 +185,7 @@ final class RemoteSessionStore {
|
|||
runtimeState = .idle
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = ""
|
||||
clearRemoteBrowserState()
|
||||
syncLegacyDefaultsForDisconnect()
|
||||
}
|
||||
persist()
|
||||
|
|
@ -158,6 +198,7 @@ final class RemoteSessionStore {
|
|||
runtimeState = .ready
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = ""
|
||||
clearRemoteBrowserState()
|
||||
persist()
|
||||
syncLegacyDefaults(with: target)
|
||||
}
|
||||
|
|
@ -167,6 +208,11 @@ final class RemoteSessionStore {
|
|||
let targetSummary = target.connectionSummary
|
||||
|
||||
cancelLiveConnection()
|
||||
#if os(macOS)
|
||||
if target.sshKeyBookmarkData != nil {
|
||||
return await startSSHSessionMac(target: target, timeout: timeout)
|
||||
}
|
||||
#endif
|
||||
runtimeState = .connecting
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = "Opening a TCP connection to \(targetSummary)…"
|
||||
|
|
@ -231,8 +277,72 @@ final class RemoteSessionStore {
|
|||
runtimeState = activeTarget == nil ? .idle : .ready
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = activeTarget == nil ? "" : "Connection closed. The target stays selected for later."
|
||||
clearRemoteBrowserState()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func loadRemoteDirectory(path: String? = nil, timeout: TimeInterval = 8) async -> Bool {
|
||||
guard isRemotePreviewConnected, let target = activeTarget else {
|
||||
remoteBrowserStatusDetail = "Start a remote session before browsing files."
|
||||
return false
|
||||
}
|
||||
guard target.sshKeyBookmarkData != nil else {
|
||||
remoteBrowserStatusDetail = "Remote file browsing requires an SSH-key session on macOS."
|
||||
return false
|
||||
}
|
||||
|
||||
let requestedPath = normalizedRemoteBrowserPath(path ?? remoteBrowserPath)
|
||||
isRemoteBrowserLoading = true
|
||||
remoteBrowserStatusDetail = "Loading \(requestedPath)…"
|
||||
|
||||
let result = await runRemoteBrowseCommandMac(target: target, path: requestedPath, timeout: timeout)
|
||||
|
||||
isRemoteBrowserLoading = false
|
||||
switch result {
|
||||
case .success(let payload):
|
||||
remoteBrowserPath = payload.path
|
||||
remoteBrowserEntries = payload.entries
|
||||
remoteBrowserStatusDetail = payload.entries.isEmpty
|
||||
? "No entries found in \(payload.path)."
|
||||
: "Loaded \(payload.entries.count) entr\(payload.entries.count == 1 ? "y" : "ies") from \(payload.path)."
|
||||
return true
|
||||
case .failure(let detail):
|
||||
remoteBrowserEntries = []
|
||||
remoteBrowserStatusDetail = detail
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func openRemoteFilePreview(path: String, timeout: TimeInterval = 8) async -> RemotePreviewDocument? {
|
||||
guard isRemotePreviewConnected, let target = activeTarget else {
|
||||
remoteBrowserStatusDetail = "Start a remote session before opening a remote file."
|
||||
return nil
|
||||
}
|
||||
guard target.sshKeyBookmarkData != nil else {
|
||||
remoteBrowserStatusDetail = "Remote file preview requires an SSH-key session on macOS."
|
||||
return nil
|
||||
}
|
||||
|
||||
let requestedPath = normalizedRemoteBrowserPath(path)
|
||||
guard EditorViewModel.isSupportedEditorFileURL(URL(fileURLWithPath: requestedPath)) else {
|
||||
remoteBrowserStatusDetail = "Only supported text files can be opened as a remote preview."
|
||||
return nil
|
||||
}
|
||||
|
||||
remoteBrowserStatusDetail = "Opening \(requestedPath)…"
|
||||
|
||||
let result = await runRemoteReadCommandMac(target: target, path: requestedPath, timeout: timeout)
|
||||
switch result {
|
||||
case .success(let document):
|
||||
remoteBrowserStatusDetail = "Opened \(document.name) as a read-only remote preview."
|
||||
return document
|
||||
case .failure(let detail):
|
||||
remoteBrowserStatusDetail = detail
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func upsert(_ target: SavedTarget) {
|
||||
if let existingIndex = savedTargets.firstIndex(where: { $0.id == target.id }) {
|
||||
savedTargets[existingIndex] = target
|
||||
|
|
@ -293,5 +403,361 @@ final class RemoteSessionStore {
|
|||
private func cancelLiveConnection() {
|
||||
liveConnection?.cancel()
|
||||
liveConnection = nil
|
||||
#if os(macOS)
|
||||
liveSSHProcess?.terminate()
|
||||
liveSSHProcess = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func clearRemoteBrowserState() {
|
||||
remoteBrowserEntries = []
|
||||
remoteBrowserPath = "~"
|
||||
remoteBrowserStatusDetail = ""
|
||||
isRemoteBrowserLoading = false
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private struct RemoteBrowsePayload {
|
||||
let path: String
|
||||
let entries: [RemoteFileEntry]
|
||||
}
|
||||
|
||||
private enum RemoteBrowseResult {
|
||||
case success(RemoteBrowsePayload)
|
||||
case failure(String)
|
||||
}
|
||||
|
||||
private enum RemoteReadResult {
|
||||
case success(RemotePreviewDocument)
|
||||
case failure(String)
|
||||
}
|
||||
|
||||
private func startSSHSessionMac(target: SavedTarget, timeout: TimeInterval) async -> Bool {
|
||||
guard let bookmarkData = target.sshKeyBookmarkData else {
|
||||
runtimeState = .failed
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = "The selected SSH key is no longer available."
|
||||
return false
|
||||
}
|
||||
guard let keyURL = resolveSecurityScopedBookmarkMac(bookmarkData) else {
|
||||
runtimeState = .failed
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = "The selected SSH key could not be resolved. Re-select the key file."
|
||||
return false
|
||||
}
|
||||
|
||||
let targetSummary = target.connectionSummary
|
||||
let didAccessKey = keyURL.startAccessingSecurityScopedResource()
|
||||
let loginTarget = target.username.isEmpty ? target.host : "\(target.username)@\(target.host)"
|
||||
let connectTimeoutSeconds = max(1, Int(timeout.rounded(.up)))
|
||||
let process = Process()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
process.arguments = [
|
||||
"-N",
|
||||
"-i", keyURL.path,
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "NumberOfPasswordPrompts=0",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
"-o", "PubkeyAuthentication=yes",
|
||||
"-o", "ConnectTimeout=\(connectTimeoutSeconds)",
|
||||
"-p", "\(target.port)",
|
||||
loginTarget
|
||||
]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = stderrPipe
|
||||
|
||||
runtimeState = .connecting
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = "Starting an SSH session to \(targetSummary) with the selected key…"
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
let completionGate = RemoteSessionCompletionGate()
|
||||
|
||||
@Sendable func finish(success: Bool, state: RuntimeState, detail: String, shouldTerminate: Bool) {
|
||||
guard completionGate.claim() else { return }
|
||||
if didAccessKey {
|
||||
keyURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
Task { @MainActor in
|
||||
if self.liveSSHProcess === process {
|
||||
if shouldTerminate {
|
||||
process.terminate()
|
||||
}
|
||||
if success {
|
||||
self.runtimeState = state
|
||||
self.sessionStartedAt = Date()
|
||||
self.sessionStatusDetail = detail
|
||||
} else {
|
||||
self.liveSSHProcess = nil
|
||||
self.runtimeState = self.activeTarget == nil ? .idle : state
|
||||
self.sessionStartedAt = nil
|
||||
self.sessionStatusDetail = detail
|
||||
}
|
||||
}
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { terminatedProcess in
|
||||
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderrText = String(data: stderrData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let detail = stderrText.isEmpty
|
||||
? "SSH session ended before it became active."
|
||||
: "SSH login failed: \(stderrText)"
|
||||
finish(
|
||||
success: false,
|
||||
state: .failed,
|
||||
detail: detail,
|
||||
shouldTerminate: terminatedProcess.isRunning
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
self.liveSSHProcess = process
|
||||
} catch {
|
||||
finish(success: false, state: .failed, detail: "SSH could not be started: \(error.localizedDescription)", shouldTerminate: false)
|
||||
return
|
||||
}
|
||||
|
||||
connectionQueue.asyncAfter(deadline: .now() + timeout) {
|
||||
guard process.isRunning else { return }
|
||||
finish(
|
||||
success: true,
|
||||
state: .active,
|
||||
detail: "SSH session active for \(targetSummary) using \(target.sshKeyDisplayName.isEmpty ? "the selected key" : target.sshKeyDisplayName).",
|
||||
shouldTerminate: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runRemoteBrowseCommandMac(
|
||||
target: SavedTarget,
|
||||
path: String,
|
||||
timeout: TimeInterval
|
||||
) async -> RemoteBrowseResult {
|
||||
guard let bookmarkData = target.sshKeyBookmarkData else {
|
||||
return .failure("The selected SSH key is no longer available.")
|
||||
}
|
||||
guard let keyURL = resolveSecurityScopedBookmarkMac(bookmarkData) else {
|
||||
return .failure("The selected SSH key could not be resolved. Re-select the key file.")
|
||||
}
|
||||
|
||||
let didAccessKey = keyURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didAccessKey {
|
||||
keyURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let loginTarget = target.username.isEmpty ? target.host : "\(target.username)@\(target.host)"
|
||||
let connectTimeoutSeconds = max(1, Int(timeout.rounded(.up)))
|
||||
let remotePath = normalizedRemoteBrowserPath(path)
|
||||
let remoteCommand = makeRemoteBrowseCommand(for: remotePath)
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
process.arguments = [
|
||||
"-i", keyURL.path,
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "NumberOfPasswordPrompts=0",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
"-o", "PubkeyAuthentication=yes",
|
||||
"-o", "ConnectTimeout=\(connectTimeoutSeconds)",
|
||||
"-p", "\(target.port)",
|
||||
loginTarget,
|
||||
remoteCommand
|
||||
]
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
process.terminationHandler = { terminatedProcess in
|
||||
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let outputText = String(data: outputData, encoding: .utf8) ?? ""
|
||||
let errorText = String(data: errorData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
if terminatedProcess.terminationStatus != 0 {
|
||||
continuation.resume(returning: .failure(errorText.isEmpty ? "Remote file browser failed." : "Remote file browser failed: \(errorText)"))
|
||||
return
|
||||
}
|
||||
|
||||
guard let payload = self.parseRemoteBrowsePayload(outputText) else {
|
||||
continuation.resume(returning: .failure("Remote file browser returned an unreadable listing."))
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(returning: .success(payload))
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.resume(returning: .failure("SSH browser command could not be started: \(error.localizedDescription)"))
|
||||
return
|
||||
}
|
||||
|
||||
connectionQueue.asyncAfter(deadline: .now() + timeout) {
|
||||
guard process.isRunning else { return }
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runRemoteReadCommandMac(
|
||||
target: SavedTarget,
|
||||
path: String,
|
||||
timeout: TimeInterval
|
||||
) async -> RemoteReadResult {
|
||||
guard let bookmarkData = target.sshKeyBookmarkData else {
|
||||
return .failure("The selected SSH key is no longer available.")
|
||||
}
|
||||
guard let keyURL = resolveSecurityScopedBookmarkMac(bookmarkData) else {
|
||||
return .failure("The selected SSH key could not be resolved. Re-select the key file.")
|
||||
}
|
||||
|
||||
let didAccessKey = keyURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didAccessKey {
|
||||
keyURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let loginTarget = target.username.isEmpty ? target.host : "\(target.username)@\(target.host)"
|
||||
let connectTimeoutSeconds = max(1, Int(timeout.rounded(.up)))
|
||||
let remotePath = normalizedRemoteBrowserPath(path)
|
||||
let remoteCommand = makeRemoteReadCommand(for: remotePath)
|
||||
let outputMarker = Data("__NVE_REMOTE_FILE__\n".utf8)
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
process.arguments = [
|
||||
"-i", keyURL.path,
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "NumberOfPasswordPrompts=0",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
"-o", "PubkeyAuthentication=yes",
|
||||
"-o", "ConnectTimeout=\(connectTimeoutSeconds)",
|
||||
"-p", "\(target.port)",
|
||||
loginTarget,
|
||||
remoteCommand
|
||||
]
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
process.terminationHandler = { terminatedProcess in
|
||||
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorText = String(data: errorData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
if terminatedProcess.terminationStatus != 0 {
|
||||
continuation.resume(returning: .failure(errorText.isEmpty ? "Remote file preview failed." : "Remote file preview failed: \(errorText)"))
|
||||
return
|
||||
}
|
||||
|
||||
guard outputData.starts(with: outputMarker) else {
|
||||
continuation.resume(returning: .failure("Remote file preview returned unreadable data."))
|
||||
return
|
||||
}
|
||||
|
||||
let payloadData = outputData.dropFirst(outputMarker.count)
|
||||
let previewByteLimit = 1_048_576
|
||||
if payloadData.count > previewByteLimit {
|
||||
continuation.resume(returning: .failure("Remote file preview is limited to 1 MB in Phase 7."))
|
||||
return
|
||||
}
|
||||
|
||||
let content = String(decoding: payloadData, as: UTF8.self)
|
||||
let name = URL(fileURLWithPath: remotePath).lastPathComponent
|
||||
continuation.resume(returning: .success(RemotePreviewDocument(name: name, path: remotePath, content: content)))
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.resume(returning: .failure("SSH preview command could not be started: \(error.localizedDescription)"))
|
||||
return
|
||||
}
|
||||
|
||||
connectionQueue.asyncAfter(deadline: .now() + timeout) {
|
||||
guard process.isRunning else { return }
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func normalizedRemoteBrowserPath(_ path: String) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "~" : trimmed
|
||||
}
|
||||
|
||||
private nonisolated func makeRemoteBrowseCommand(for path: String) -> String {
|
||||
let pathArgument = path == "~" ? "~" : shellQuoted(path)
|
||||
return "cd -- \(pathArgument) && pwd && printf '__NVE_BROWSER__\\n' && LC_ALL=C /bin/ls -1ApA"
|
||||
}
|
||||
|
||||
private nonisolated func makeRemoteReadCommand(for path: String) -> String {
|
||||
let pathArgument = path == "~" ? "~" : shellQuoted(path)
|
||||
return "printf '__NVE_REMOTE_FILE__\\n' && LC_ALL=C /usr/bin/head -c 1048577 -- \(pathArgument)"
|
||||
}
|
||||
|
||||
private nonisolated func parseRemoteBrowsePayload(_ output: String) -> RemoteBrowsePayload? {
|
||||
let separator = "\n__NVE_BROWSER__\n"
|
||||
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let range = trimmedOutput.range(of: separator) else { return nil }
|
||||
let path = String(trimmedOutput[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let listing = String(trimmedOutput[range.upperBound...])
|
||||
let entries = listing
|
||||
.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
.compactMap { rawEntry -> RemoteFileEntry? in
|
||||
let line = String(rawEntry)
|
||||
guard line != "." && line != ".." else { return nil }
|
||||
let isDirectory = line.hasSuffix("/")
|
||||
let displayName = isDirectory ? String(line.dropLast()) : line
|
||||
let fullPath = path == "/" ? "/\(displayName)" : "\(path)/\(displayName)"
|
||||
return RemoteFileEntry(name: displayName, path: fullPath, isDirectory: isDirectory)
|
||||
}
|
||||
.sorted {
|
||||
if $0.isDirectory != $1.isDirectory {
|
||||
return $0.isDirectory && !$1.isDirectory
|
||||
}
|
||||
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
return RemoteBrowsePayload(path: path.isEmpty ? "~" : path, entries: entries)
|
||||
}
|
||||
|
||||
private nonisolated func shellQuoted(_ value: String) -> String {
|
||||
"'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
private func resolveSecurityScopedBookmarkMac(_ data: Data) -> URL? {
|
||||
var isStale = false
|
||||
guard let resolved = try? URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [.withSecurityScope, .withoutUI],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,6 +390,8 @@ final class TabData: Identifiable {
|
|||
fileprivate(set) var lastKnownFileModificationDate: Date?
|
||||
fileprivate(set) var isLoadingContent: Bool
|
||||
fileprivate(set) var isLargeFileCandidate: Bool
|
||||
fileprivate(set) var remotePreviewPath: String?
|
||||
fileprivate(set) var isReadOnlyPreview: Bool
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
|
|
@ -402,7 +404,9 @@ final class TabData: Identifiable {
|
|||
lastSavedFingerprint: UInt64? = nil,
|
||||
lastKnownFileModificationDate: Date? = nil,
|
||||
isLoadingContent: Bool = false,
|
||||
isLargeFileCandidate: Bool = false
|
||||
isLargeFileCandidate: Bool = false,
|
||||
remotePreviewPath: String? = nil,
|
||||
isReadOnlyPreview: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
|
|
@ -415,6 +419,8 @@ final class TabData: Identifiable {
|
|||
self.lastKnownFileModificationDate = lastKnownFileModificationDate
|
||||
self.isLoadingContent = isLoadingContent
|
||||
self.isLargeFileCandidate = isLargeFileCandidate
|
||||
self.remotePreviewPath = remotePreviewPath
|
||||
self.isReadOnlyPreview = isReadOnlyPreview
|
||||
}
|
||||
|
||||
var content: String { contentStorage.string() }
|
||||
|
|
@ -979,6 +985,7 @@ class EditorViewModel {
|
|||
// Tab-scoped content update API that centralizes dirty/idempotence behavior.
|
||||
func updateTabContent(tabID: UUID, content: String) {
|
||||
guard let index = tabIndex(for: tabID) else { return }
|
||||
guard !tabs[index].isReadOnlyPreview else { return }
|
||||
if tabs[index].isLoadingContent {
|
||||
// During staged file load, content updates are system-driven; do not mark dirty.
|
||||
_ = applyTabCommand(
|
||||
|
|
@ -1019,6 +1026,7 @@ class EditorViewModel {
|
|||
// Incremental piece-table mutation path used by the editor delegates for large content responsiveness.
|
||||
func applyTabContentEdit(tabID: UUID, range: NSRange, replacement: String) {
|
||||
guard let index = tabIndex(for: tabID) else { return }
|
||||
guard !tabs[index].isReadOnlyPreview else { return }
|
||||
guard !tabs[index].isLoadingContent else { return }
|
||||
|
||||
let outcome = applyTabCommand(
|
||||
|
|
@ -1068,6 +1076,7 @@ class EditorViewModel {
|
|||
// Saves tab content to the existing file URL or falls back to Save As.
|
||||
func saveFile(tabID: UUID, allowExternalOverwrite: Bool = false) {
|
||||
guard let index = tabIndex(for: tabID) else { return }
|
||||
guard !tabs[index].isReadOnlyPreview else { return }
|
||||
if !allowExternalOverwrite,
|
||||
let conflict = detectExternalConflict(for: tabs[index]) {
|
||||
pendingExternalFileConflict = conflict
|
||||
|
|
@ -1143,6 +1152,7 @@ class EditorViewModel {
|
|||
// Saves tab content to a user-selected path on macOS.
|
||||
func saveFileAs(tabID: UUID) {
|
||||
guard let index = tabIndex(for: tabID) else { return }
|
||||
guard !tabs[index].isReadOnlyPreview else { return }
|
||||
#if os(macOS)
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = tabs[index].name
|
||||
|
|
@ -1322,6 +1332,56 @@ class EditorViewModel {
|
|||
return true
|
||||
}
|
||||
|
||||
func openRemotePreviewDocument(name: String, remotePath: String, content: String) {
|
||||
let trimmedPath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedPath.isEmpty else { return }
|
||||
|
||||
let pseudoURL = URL(fileURLWithPath: trimmedPath)
|
||||
let detectedLanguage = LanguageDetector.shared.preferredLanguage(for: pseudoURL)
|
||||
?? languageMap[pseudoURL.pathExtension.lowercased()]
|
||||
?? "plain"
|
||||
let languageLocked = detectedLanguage != "plain"
|
||||
let title = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? pseudoURL.lastPathComponent
|
||||
: name
|
||||
|
||||
if let existingIndex = tabs.firstIndex(where: { $0.remotePreviewPath == trimmedPath }) {
|
||||
cancelPendingLanguageDetection(for: tabs[existingIndex].id)
|
||||
tabs[existingIndex].name = title
|
||||
tabs[existingIndex].fileURL = nil
|
||||
tabs[existingIndex].language = detectedLanguage
|
||||
tabs[existingIndex].languageLocked = languageLocked
|
||||
_ = tabs[existingIndex].replaceContentStorage(with: content, markDirty: false, compareIfLengthAtMost: nil)
|
||||
tabs[existingIndex].markClean(withFingerprint: nil)
|
||||
tabs[existingIndex].updateLastKnownFileModificationDate(nil)
|
||||
tabs[existingIndex].isLoadingContent = false
|
||||
tabs[existingIndex].isLargeFileCandidate = false
|
||||
tabs[existingIndex].remotePreviewPath = trimmedPath
|
||||
tabs[existingIndex].isReadOnlyPreview = true
|
||||
selectedTabID = tabs[existingIndex].id
|
||||
recordTabStateMutation(rebuildIndexes: true)
|
||||
return
|
||||
}
|
||||
|
||||
let tab = TabData(
|
||||
name: title,
|
||||
content: content,
|
||||
language: detectedLanguage,
|
||||
fileURL: nil,
|
||||
languageLocked: languageLocked,
|
||||
isDirty: false,
|
||||
lastSavedFingerprint: nil,
|
||||
lastKnownFileModificationDate: nil,
|
||||
isLoadingContent: false,
|
||||
isLargeFileCandidate: false,
|
||||
remotePreviewPath: trimmedPath,
|
||||
isReadOnlyPreview: true
|
||||
)
|
||||
tabs.append(tab)
|
||||
selectedTabID = tab.id
|
||||
recordTabStateMutation(rebuildIndexes: true)
|
||||
}
|
||||
|
||||
nonisolated static func isSupportedEditorFileURL(_ url: URL) -> Bool {
|
||||
if url.hasDirectoryPath { return false }
|
||||
let fileName = url.lastPathComponent.lowercased()
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ extension ContentView {
|
|||
|
||||
func saveCurrentTabFromToolbar() {
|
||||
guard let tab = viewModel.selectedTab else { return }
|
||||
guard !tab.isReadOnlyPreview else { return }
|
||||
#if os(macOS)
|
||||
viewModel.saveFile(tabID: tab.id)
|
||||
#else
|
||||
|
|
@ -106,6 +107,7 @@ extension ContentView {
|
|||
|
||||
func saveCurrentTabAsFromToolbar() {
|
||||
guard let tab = viewModel.selectedTab else { return }
|
||||
guard !tab.isReadOnlyPreview else { return }
|
||||
#if os(macOS)
|
||||
viewModel.saveFileAs(tabID: tab.id)
|
||||
#else
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ extension ContentView {
|
|||
Button(action: { saveCurrentTabFromToolbar() }) {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
}
|
||||
.disabled(viewModel.selectedTab == nil)
|
||||
.disabled(viewModel.selectedTab == nil || viewModel.selectedTab?.isReadOnlyPreview == true)
|
||||
.help("Save File (Cmd+S)")
|
||||
.accessibilityLabel("Save file")
|
||||
.accessibilityHint("Saves the current tab")
|
||||
|
|
|
|||
|
|
@ -3182,6 +3182,7 @@ struct ContentView: View {
|
|||
viewModel.selectedTab?.content ?? singleContent
|
||||
},
|
||||
set: { newValue in
|
||||
guard viewModel.selectedTab?.isReadOnlyPreview != true else { return }
|
||||
viewModel.updateTabContent(tabID: selectedID, content: newValue)
|
||||
}
|
||||
)
|
||||
|
|
@ -3236,6 +3237,10 @@ struct ContentView: View {
|
|||
return currentDocumentUTF16Length >= 300_000
|
||||
}
|
||||
|
||||
private var isSelectedTabReadOnlyPreview: Bool {
|
||||
viewModel.selectedTab?.isReadOnlyPreview == true
|
||||
}
|
||||
|
||||
private var shouldUseDeferredLargeFileOpenMode: Bool {
|
||||
largeFileOpenModeRaw == "deferred" || largeFileOpenModeRaw == "plainText"
|
||||
}
|
||||
|
|
@ -4206,6 +4211,7 @@ struct ContentView: View {
|
|||
autoCloseBracketsEnabled: autoCloseBracketsEnabled,
|
||||
highlightRefreshToken: highlightRefreshToken,
|
||||
isTabLoadingContent: viewModel.selectedTab?.isLoadingContent ?? false,
|
||||
isReadOnly: isSelectedTabReadOnlyPreview,
|
||||
onTextMutation: { mutation in
|
||||
viewModel.applyTabContentEdit(
|
||||
tabID: mutation.documentID,
|
||||
|
|
@ -5031,11 +5037,18 @@ struct ContentView: View {
|
|||
Button {
|
||||
viewModel.selectTab(id: tab.id)
|
||||
} label: {
|
||||
Text(tab.name + (tab.isDirty ? " •" : ""))
|
||||
.lineLimit(1)
|
||||
.font(.system(size: 12, weight: viewModel.selectedTabID == tab.id ? .semibold : .regular))
|
||||
.padding(.leading, 10)
|
||||
.padding(.vertical, 6)
|
||||
HStack(spacing: 6) {
|
||||
Text(tab.name + (tab.isDirty ? " •" : ""))
|
||||
.lineLimit(1)
|
||||
.font(.system(size: 12, weight: viewModel.selectedTabID == tab.id ? .semibold : .regular))
|
||||
if tab.isReadOnlyPreview {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if os(macOS)
|
||||
|
|
|
|||
|
|
@ -1902,6 +1902,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
let autoCloseBracketsEnabled: Bool
|
||||
let highlightRefreshToken: Int
|
||||
let isTabLoadingContent: Bool
|
||||
let isReadOnly: Bool
|
||||
let onTextMutation: ((EditorTextMutation) -> Void)?
|
||||
|
||||
private var fontName: String {
|
||||
|
|
@ -2030,7 +2031,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
let textView = AcceptingTextView(frame: .zero)
|
||||
textView.identifier = NSUserInterfaceItemIdentifier("NeonEditorTextView")
|
||||
// Configure editing behavior and visuals
|
||||
textView.isEditable = true
|
||||
textView.isEditable = !isReadOnly
|
||||
textView.isRichText = false
|
||||
textView.usesFindBar = !isLargeFileMode
|
||||
textView.usesInspectorBar = false
|
||||
|
|
@ -2156,7 +2157,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
if let textView = nsView.documentView as? NSTextView {
|
||||
var needsLayoutRefresh = false
|
||||
var didChangeRulerConfiguration = false
|
||||
textView.isEditable = true
|
||||
textView.isEditable = !isReadOnly
|
||||
textView.isSelectable = true
|
||||
let acceptingView = textView as? AcceptingTextView
|
||||
let isDropApplyInFlight = acceptingView?.isApplyingDroppedContent ?? false
|
||||
|
|
@ -2537,7 +2538,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
let remaining = targetLength - location
|
||||
guard remaining > 0 else {
|
||||
self.isInstallingLargeText = false
|
||||
textView.isEditable = true
|
||||
textView.isEditable = !parent.isReadOnly
|
||||
let safeLocation = min(max(0, previousSelection.location), targetLength)
|
||||
let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation))
|
||||
textView.setSelectedRange(NSRange(location: safeLocation, length: safeLength))
|
||||
|
|
@ -4053,6 +4054,7 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
let autoCloseBracketsEnabled: Bool
|
||||
let highlightRefreshToken: Int
|
||||
let isTabLoadingContent: Bool
|
||||
let isReadOnly: Bool
|
||||
let onTextMutation: ((EditorTextMutation) -> Void)?
|
||||
|
||||
private var fontName: String {
|
||||
|
|
@ -4085,6 +4087,7 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
let theme = currentEditorTheme(colorScheme: colorScheme)
|
||||
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = !isReadOnly
|
||||
let initialFont = resolvedUIFont()
|
||||
textView.font = initialFont
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
|
|
@ -4155,6 +4158,7 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
func updateUIView(_ uiView: LineNumberedTextViewContainer, context: Context) {
|
||||
let textView = uiView.textView
|
||||
context.coordinator.parent = self
|
||||
textView.isEditable = !isReadOnly
|
||||
let didSwitchDocument = context.coordinator.lastDocumentID != documentID
|
||||
let didFinishTabLoad = (context.coordinator.lastTabLoadingContent == true) && !isTabLoadingContent
|
||||
let didReceiveExternalEdit = context.coordinator.lastExternalEditRevision != externalEditRevision
|
||||
|
|
@ -4406,7 +4410,7 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
let remaining = targetLength - location
|
||||
guard remaining > 0 else {
|
||||
self.isInstallingLargeText = false
|
||||
textView.isEditable = true
|
||||
textView.isEditable = !parent.isReadOnly
|
||||
let safeLocation = min(max(0, previousSelection.location), targetLength)
|
||||
let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation))
|
||||
textView.selectedRange = NSRange(location: safeLocation, length: safeLength)
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ struct NeonSettingsView: View {
|
|||
@State private var diagnosticsCopyStatus: String = ""
|
||||
@State private var remotePreparationStatus: String = ""
|
||||
@State private var remoteConnectNickname: String = ""
|
||||
@State private var remotePortDraft: String = "22"
|
||||
@State private var remoteBrowserPathDraft: String = "~"
|
||||
#if os(macOS)
|
||||
@State private var remoteSSHKeyBookmarkData: Data? = nil
|
||||
@State private var remoteSSHKeyDisplayName: String = ""
|
||||
#endif
|
||||
@State private var supportRefreshTask: Task<Void, Never>?
|
||||
@State private var isDiscoveringFonts: Bool = false
|
||||
private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md")
|
||||
|
|
@ -1811,6 +1817,11 @@ struct NeonSettingsView: View {
|
|||
remoteConnectNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var sanitizedRemotePort: Int {
|
||||
let parsedPort = Int(remotePortDraft.trimmingCharacters(in: .whitespacesAndNewlines)) ?? remotePort
|
||||
return min(max(parsedPort, 1), 65535)
|
||||
}
|
||||
|
||||
private var canSubmitRemoteConnectDraft: Bool {
|
||||
remoteSessionsEnabled && !trimmedRemoteHost.isEmpty
|
||||
}
|
||||
|
|
@ -1820,13 +1831,13 @@ struct NeonSettingsView: View {
|
|||
return "Local workspace only. Remote modules stay inactive until you enable this preview."
|
||||
}
|
||||
if remoteSessionStore.isRemotePreviewConnecting, let activeTarget = remoteSessionStore.activeTarget {
|
||||
return "Connecting to \(activeTarget.connectionSummary). This TCP handshake is user-triggered and still does not enable file browsing or remote editing."
|
||||
return "Connecting to \(activeTarget.connectionSummary). This login is user-triggered and still does not enable file browsing or remote editing."
|
||||
}
|
||||
if remoteSessionStore.isRemotePreviewConnected, let activeTarget = remoteSessionStore.activeTarget {
|
||||
return "Remote session active for \(activeTarget.connectionSummary). Phase 4 keeps the connection explicit and does not enable file browsing or remote editing."
|
||||
return "Remote session active for \(activeTarget.connectionSummary). Phase 6 adds a read-only file browser on macOS while remote editing stays inactive."
|
||||
}
|
||||
if let activeTarget = remoteSessionStore.activeTarget {
|
||||
return "Active preview target: \(activeTarget.connectionSummary). Starting a remote session now performs an explicit TCP connection attempt in Phase 4."
|
||||
return "Active preview target: \(activeTarget.connectionSummary). Starting a remote session now performs an explicit SSH login on macOS when a key is selected, or a TCP connection test otherwise."
|
||||
}
|
||||
if !remoteSessionStore.savedTargets.isEmpty {
|
||||
return "Remote preview is enabled. Choose a saved target or create a new local preview target when ready."
|
||||
|
|
@ -1854,14 +1865,32 @@ struct NeonSettingsView: View {
|
|||
nickname: trimmedRemoteConnectNickname,
|
||||
host: trimmedRemoteHost,
|
||||
username: trimmedRemoteUsername,
|
||||
port: remotePort
|
||||
port: sanitizedRemotePort,
|
||||
sshKeyBookmarkData: {
|
||||
#if os(macOS)
|
||||
remoteSSHKeyBookmarkData
|
||||
#else
|
||||
nil
|
||||
#endif
|
||||
}(),
|
||||
sshKeyDisplayName: {
|
||||
#if os(macOS)
|
||||
remoteSSHKeyDisplayName
|
||||
#else
|
||||
""
|
||||
#endif
|
||||
}()
|
||||
) else {
|
||||
return
|
||||
}
|
||||
remoteSessionsEnabled = true
|
||||
remotePreparedTarget = target.connectionSummary
|
||||
remoteConnectNickname = target.nickname
|
||||
remotePreparationStatus = "Local preview target selected. No network connection has been opened."
|
||||
remotePort = target.port
|
||||
remotePortDraft = String(target.port)
|
||||
remotePreparationStatus = target.sshKeyBookmarkData == nil
|
||||
? "Local preview target selected. No network connection has been opened."
|
||||
: "SSH target selected. The login remains inactive until you start a session."
|
||||
showRemoteConnectSheet = false
|
||||
}
|
||||
|
||||
|
|
@ -2046,12 +2075,214 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func syncRemotePortDraftFromStoredValue() {
|
||||
remotePortDraft = String(remotePort)
|
||||
}
|
||||
|
||||
private func applyRemotePortDraft() {
|
||||
let sanitizedPort = sanitizedRemotePort
|
||||
remotePort = sanitizedPort
|
||||
remotePortDraft = String(sanitizedPort)
|
||||
}
|
||||
|
||||
private func syncRemoteBrowserPathDraft() {
|
||||
remoteBrowserPathDraft = remoteSessionStore.remoteBrowserPath
|
||||
}
|
||||
|
||||
private func loadRemoteBrowserPath(_ path: String? = nil) {
|
||||
#if os(macOS)
|
||||
Task {
|
||||
let didLoad = await remoteSessionStore.loadRemoteDirectory(path: path)
|
||||
await MainActor.run {
|
||||
syncRemoteBrowserPathDraft()
|
||||
if didLoad {
|
||||
remotePreparationStatus = remoteSessionStore.remoteBrowserStatusDetail
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func browseRemoteParentDirectory() {
|
||||
#if os(macOS)
|
||||
let currentPath = remoteSessionStore.remoteBrowserPath
|
||||
guard currentPath != "/" && currentPath != "~" else { return }
|
||||
let nsPath = currentPath as NSString
|
||||
let parentPath = nsPath.deletingLastPathComponent
|
||||
loadRemoteBrowserPath(parentPath.isEmpty ? "/" : parentPath)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func applyRemoteBrowserPathDraft() {
|
||||
loadRemoteBrowserPath(remoteBrowserPathDraft)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func openRemotePreviewFile(_ entry: RemoteSessionStore.RemoteFileEntry) {
|
||||
Task {
|
||||
guard let document = await remoteSessionStore.openRemoteFilePreview(path: entry.path) else {
|
||||
await MainActor.run {
|
||||
remotePreparationStatus = remoteSessionStore.remoteBrowserStatusDetail
|
||||
}
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
guard let activeEditorViewModel = WindowViewModelRegistry.shared.activeViewModel() else {
|
||||
remotePreparationStatus = "Bring an editor window to the front before opening a remote preview."
|
||||
return
|
||||
}
|
||||
activeEditorViewModel.openRemotePreviewDocument(
|
||||
name: document.name,
|
||||
remotePath: document.path,
|
||||
content: document.content
|
||||
)
|
||||
remotePreparationStatus = "Opened \(document.name) as a read-only remote preview."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncRemoteSSHKeyDraftFromActiveTarget() {
|
||||
remoteSSHKeyBookmarkData = remoteSessionStore.activeTarget?.sshKeyBookmarkData
|
||||
remoteSSHKeyDisplayName = remoteSessionStore.activeTarget?.sshKeyDisplayName ?? ""
|
||||
}
|
||||
|
||||
private func chooseRemoteSSHKey() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = []
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.showsHiddenFiles = true
|
||||
panel.title = "Select SSH Private Key"
|
||||
panel.prompt = "Use Key"
|
||||
|
||||
guard panel.runModal() == .OK, let keyURL = panel.url else { return }
|
||||
|
||||
let didAccess = keyURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didAccess {
|
||||
keyURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
guard let bookmarkData = try? keyURL.bookmarkData(
|
||||
options: [.withSecurityScope],
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
) else {
|
||||
remotePreparationStatus = "The selected SSH key could not be stored securely."
|
||||
return
|
||||
}
|
||||
|
||||
remoteSSHKeyBookmarkData = bookmarkData
|
||||
remoteSSHKeyDisplayName = keyURL.lastPathComponent
|
||||
}
|
||||
|
||||
private func clearRemoteSSHKeySelection() {
|
||||
remoteSSHKeyBookmarkData = nil
|
||||
remoteSSHKeyDisplayName = ""
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private var canShowRemoteBrowser: Bool {
|
||||
remoteSessionStore.isRemotePreviewConnected && remoteSessionStore.activeTarget?.sshKeyBookmarkData != nil
|
||||
}
|
||||
|
||||
private var remoteBrowserSection: some View {
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
HStack(spacing: UI.space12) {
|
||||
TextField("Remote Path", text: $remoteBrowserPathDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
applyRemoteBrowserPathDraft()
|
||||
}
|
||||
|
||||
Button("Refresh") {
|
||||
loadRemoteBrowserPath(remoteBrowserPathDraft)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(remoteSessionStore.isRemoteBrowserLoading)
|
||||
|
||||
Button("Up") {
|
||||
browseRemoteParentDirectory()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(remoteSessionStore.isRemoteBrowserLoading || remoteSessionStore.remoteBrowserPath == "/" || remoteSessionStore.remoteBrowserPath == "~")
|
||||
}
|
||||
|
||||
Text(remoteSessionStore.remoteBrowserStatusDetail.isEmpty ? "Browse the active remote session read-only. Folders load on demand, and supported text files open as read-only previews." : remoteSessionStore.remoteBrowserStatusDetail)
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if remoteSessionStore.isRemoteBrowserLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if remoteSessionStore.remoteBrowserEntries.isEmpty, !remoteSessionStore.remoteBrowserStatusDetail.isEmpty, !remoteSessionStore.isRemoteBrowserLoading {
|
||||
Text("No remote entries loaded yet.")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: UI.space8) {
|
||||
ForEach(remoteSessionStore.remoteBrowserEntries) { entry in
|
||||
Button {
|
||||
if entry.isDirectory {
|
||||
loadRemoteBrowserPath(entry.path)
|
||||
} else {
|
||||
openRemotePreviewFile(entry)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: UI.space8) {
|
||||
Image(systemName: entry.isDirectory ? "folder" : "doc.text")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.name)
|
||||
.foregroundStyle(.primary)
|
||||
Text(entry.path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if entry.isDirectory {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Open Preview")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(UI.space10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(inputFieldBackground.opacity(0.85))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(remoteSessionStore.isRemoteBrowserLoading)
|
||||
.accessibilityLabel(entry.isDirectory ? "Open remote folder \(entry.name)" : "Open read-only remote preview \(entry.name)")
|
||||
.accessibilityHint(entry.isDirectory ? "Loads the selected remote folder" : "Opens the selected remote file as a read-only preview tab")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
syncRemoteBrowserPathDraft()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var remoteConnectSheet: some View {
|
||||
VStack(alignment: .leading, spacing: UI.space16) {
|
||||
Text("Remote Connect")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
Text("Connect stores and selects a preview target. Phase 4 adds an explicit TCP connection attempt only when you start a session, while file browsing and remote editing stay inactive.")
|
||||
Text("Connect stores and selects a preview target. On macOS, selecting an SSH key enables an explicit SSH login only when you start a session. Phase 7 adds read-only browsing and read-only file previews after login, while remote editing still stays inactive.")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
|
|
@ -2070,10 +2301,44 @@ struct NeonSettingsView: View {
|
|||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
#endif
|
||||
Stepper(value: $remotePort, in: 1...65535) {
|
||||
Text("Port \(remotePort)")
|
||||
.font(.body.monospacedDigit())
|
||||
TextField("Port", text: $remotePortDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
.onSubmit {
|
||||
applyRemotePortDraft()
|
||||
}
|
||||
|
||||
Text("Port range: 1-65535. Port 22 is the standard SSH port.")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
#if os(macOS)
|
||||
VStack(alignment: .leading, spacing: UI.space8) {
|
||||
HStack(spacing: UI.space12) {
|
||||
Button(remoteSSHKeyBookmarkData == nil ? "Choose SSH Key…" : "Change SSH Key…") {
|
||||
chooseRemoteSSHKey()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
if remoteSSHKeyBookmarkData != nil {
|
||||
Button("Clear Key") {
|
||||
clearRemoteSSHKeySelection()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
Text(remoteSSHKeyDisplayName.isEmpty ? "No SSH key selected. Without a key, Start Session falls back to a TCP connection test." : "Selected key: \(remoteSSHKeyDisplayName)")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#else
|
||||
Text("SSH-key login is currently available on macOS only. iPhone and iPad keep using the local prepared-target flow.")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
HStack(spacing: UI.space12) {
|
||||
|
|
@ -2093,6 +2358,12 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
.padding(UI.space20)
|
||||
.frame(minWidth: 320, idealWidth: 420)
|
||||
.onAppear {
|
||||
syncRemotePortDraftFromStoredValue()
|
||||
#if os(macOS)
|
||||
syncRemoteSSHKeyDraftFromActiveTarget()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteSection: some View {
|
||||
|
|
@ -2126,12 +2397,12 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
|
||||
settingsCardSection(
|
||||
title: "Phase 4 Scope",
|
||||
title: "Phase 7 Scope",
|
||||
icon: "lock.shield",
|
||||
emphasis: .secondary,
|
||||
showsAccentStripe: false
|
||||
) {
|
||||
Text("Phase 4 adds an explicit user-triggered TCP connection attempt with timeout and manual stop. SSH login, remote file browsing, and live remote editing remain inactive.")
|
||||
Text("Phase 7 adds a macOS-only read-only remote file browser plus read-only remote file previews after SSH-key login. iPhone and iPad continue to use local target preparation only, and remote editing remains inactive.")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
@ -2150,6 +2421,15 @@ struct NeonSettingsView: View {
|
|||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
#if os(macOS)
|
||||
if canShowRemoteBrowser {
|
||||
GroupBox("Remote Browser") {
|
||||
remoteBrowserSection
|
||||
.padding(UI.groupPadding)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if !remoteSessionStore.savedTargets.isEmpty {
|
||||
remoteSavedTargetsList
|
||||
}
|
||||
|
|
@ -2163,8 +2443,8 @@ struct NeonSettingsView: View {
|
|||
.padding(UI.groupPadding)
|
||||
}
|
||||
|
||||
GroupBox("Phase 4 Scope") {
|
||||
Text("Phase 4 adds an explicit user-triggered TCP connection attempt with timeout and manual stop. SSH login, remote file browsing, and live remote editing remain inactive.")
|
||||
GroupBox("Phase 7 Scope") {
|
||||
Text("Phase 7 adds a macOS-only read-only remote file browser plus read-only remote file previews after SSH-key login. iPhone and iPad continue to use local target preparation only, and remote editing remains inactive.")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
|
@ -2178,6 +2458,12 @@ struct NeonSettingsView: View {
|
|||
remotePreparationStatus = ""
|
||||
}
|
||||
}
|
||||
.onChange(of: remotePort) { _, newValue in
|
||||
remotePortDraft = String(newValue)
|
||||
}
|
||||
.onChange(of: remoteSessionStore.remoteBrowserPath) { _, newValue in
|
||||
remoteBrowserPathDraft = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private var aiSection: some View {
|
||||
|
|
|
|||
Loading…
Reference in a new issue