diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index e6c790365..18e2c00fd 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1933,6 +1933,7 @@ "Local" = "Local"; "Local %@ configuration" = "Local %@ configuration"; "Local Changes" = "Local Changes"; +"Local Folder" = "Local Folder"; "Local History" = "Local History"; "Local History is available for text files" = "Local History is available for text files"; "Local History…" = "Local History…"; @@ -3335,6 +3336,7 @@ "Signed in through the ACP gateway with this provider's API key." = "Signed in through the ACP gateway with this provider's API key."; "Single file" = "Single file"; "Single row" = "Single row"; +"Size" = "Size"; "Skill actions" = "Skill actions"; "Skill files" = "Skill files"; "Skill imports cannot contain symbolic links or special files." = "Skill imports cannot contain symbolic links or special files."; @@ -3875,6 +3877,8 @@ "This file is outside the current project." = "This file is outside the current project."; "This file was deleted outside Lithe. Your editor content is preserved." = "This file was deleted outside Lithe. Your editor content is preserved."; "This folder already belongs to a Git repository" = "This folder already belongs to a Git repository"; +"This folder cannot be read." = "This folder cannot be read."; +"This folder is empty" = "This folder is empty"; "This page" = "This page"; "This panel needs exactly one matching plugin instance in the workspace." = "This panel needs exactly one matching plugin instance in the workspace."; "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept."; @@ -6852,6 +6856,7 @@ "native.openInLithe" = "Open in Lithe"; "native.openInNewWindow" = "Open in New Window"; "native.openInRightSplit" = "Open in Right Split"; +"native.openLocalFolder" = "Local Folder"; "native.openLogDirectory" = "Open Log Directory"; "native.openMarketplace" = "Open Marketplace"; "native.openModulePomXml" = "Open module pom.xml"; @@ -7729,6 +7734,7 @@ "native.signedInThroughTheACPGatewayWithThisProviderSAPIKey" = "Signed in through the ACP gateway with this provider's API key."; "native.singleFile" = "Single file"; "native.singleRow" = "Single row"; +"native.size" = "Size"; "native.skillActions" = "Skill actions"; "native.skillFiles" = "Skill files"; "native.skillImportsCannotContainSymbolicLinksOrSpecialFiles" = "Skill imports cannot contain symbolic links or special files."; @@ -8274,6 +8280,8 @@ "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved" = "This file was deleted outside Lithe. Your editor content is preserved."; "native.thisFolder" = "This Folder"; "native.thisFolderAlreadyBelongsToAGitRepository" = "This folder already belongs to a Git repository"; +"native.thisFolderCannotBeRead" = "This folder cannot be read."; +"native.thisFolderIsEmpty" = "This folder is empty"; "native.thisHookNoLongerExistsReloadTheLibrary" = "This Hook no longer exists. Reload the library."; "native.thisMac" = "This Mac"; "native.thisMacOnly" = "This Mac only"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index fce62f8a8..41473b776 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1933,6 +1933,7 @@ "Local" = "本地"; "Local %@ configuration" = "使用本机 %@ 配置"; "Local Changes" = "本地更改"; +"Local Folder" = "本地文件夹"; "Local History" = "本地历史"; "Local History is available for text files" = "本地历史仅适用于文本文件"; "Local History…" = "本地历史…"; @@ -3335,6 +3336,7 @@ "Signed in through the ACP gateway with this provider's API key." = "使用该服务商的 API 密钥通过 ACP 网关登录。"; "Single file" = "单文件"; "Single row" = "单行"; +"Size" = "大小"; "Skill actions" = "技能操作"; "Skill files" = "技能文件"; "Skill imports cannot contain symbolic links or special files." = "导入的技能不能包含符号链接或特殊文件。"; @@ -3875,6 +3877,8 @@ "This file is outside the current project." = "此文件不在当前项目中。"; "This file was deleted outside Lithe. Your editor content is preserved." = "该文件已被外部删除,编辑器内容已保留。"; "This folder already belongs to a Git repository" = "此目录已属于一个 Git 仓库。"; +"This folder cannot be read." = "无法读取此文件夹。"; +"This folder is empty" = "此文件夹为空"; "This page" = "此页面"; "This panel needs exactly one matching plugin instance in the workspace." = "此面板要求工作区中恰好有一个匹配的插件实例。"; "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "这会永久删除“%@”中的未提交文件和未跟踪文件,但会保留对应分支。"; @@ -6852,6 +6856,7 @@ "native.openInLithe" = "在 Lithe 中打开"; "native.openInNewWindow" = "在新窗口中打开"; "native.openInRightSplit" = "在右侧分栏打开"; +"native.openLocalFolder" = "本地文件夹"; "native.openLogDirectory" = "打开日志目录"; "native.openMarketplace" = "打开插件市场"; "native.openModulePomXml" = "打开所属模块的 pom.xml"; @@ -7729,6 +7734,7 @@ "native.signedInThroughTheACPGatewayWithThisProviderSAPIKey" = "使用该服务商的 API 密钥通过 ACP 网关登录。"; "native.singleFile" = "单文件"; "native.singleRow" = "单行"; +"native.size" = "大小"; "native.skillActions" = "技能操作"; "native.skillFiles" = "技能文件"; "native.skillImportsCannotContainSymbolicLinksOrSpecialFiles" = "导入的技能不能包含符号链接或特殊文件。"; @@ -8274,6 +8280,8 @@ "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved" = "该文件已被外部删除,编辑器内容已保留。"; "native.thisFolder" = "此文件夹"; "native.thisFolderAlreadyBelongsToAGitRepository" = "此目录已属于一个 Git 仓库。"; +"native.thisFolderCannotBeRead" = "无法读取此文件夹。"; +"native.thisFolderIsEmpty" = "此文件夹为空"; "native.thisHookNoLongerExistsReloadTheLibrary" = "该 Hook 已不存在,请重新加载资源库。"; "native.thisMac" = "本机"; "native.thisMacOnly" = "仅本机"; diff --git a/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift index c9f92b5fa..21d2b3690 100644 --- a/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift @@ -339,6 +339,27 @@ final class RemoteConnectionsFeatureModel: ObservableObject { openBrowserPath((browser.path as NSString).appendingPathComponent(entry.name)) } + /// Opens the browsed folder as a workspace. The open session passes to the + /// workspace, so it is not disconnected here. + func openBrowsedFolder() { + guard let browser, !browser.isLoading else { return } + guard let connection = connections.first(where: { $0.id == browser.connectionID }) else { + self.browser?.error = String( + format: L10n.string("Could not open the remote folder: %@"), L10n.string("The connection failed.") + ) + return + } + do { + try openWorkspace(connection, browser.path, browser.session) + browseTask?.cancel() + browseTask = nil + self.browser = nil + didOpenWorkspace = true + } catch { + self.browser?.error = String(format: L10n.string("Could not open the remote folder: %@"), error.localizedDescription) + } + } + /// Saves the browsed folder as the connection's project folder. func chooseBrowserFolder() { guard let browser else { return } diff --git a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift index e81774a0e..af0a38841 100644 --- a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift @@ -102,6 +102,8 @@ final class WorkbenchFeatureModel: ObservableObject { @Published private(set) var settingsCategoryRequest = 0 @Published var isCloneRepositoryPresented = false @Published var isRemoteConnectionsPresented = false + /// Asks whether Open should browse this Mac or a saved SSH host. + @Published var isOpenTargetChooserPresented = false /// The remote workspace whose connection was lost; the Remote Development /// window then reconnects that folder instead of opening a new one. @Published var remoteReconnect: RemoteConnectionLoss? @@ -397,6 +399,7 @@ final class WorkbenchFeatureModel: ObservableObject { isCloneRepositoryPresented = false isNewProjectPresented = false isRemoteConnectionsPresented = false + isOpenTargetChooserPresented = false } func loadLayout(for workspaceURL: URL) -> WorkbenchLayout { diff --git a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift index 22095676d..6c8fd7c49 100644 --- a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift +++ b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift @@ -167,6 +167,16 @@ struct RemoteDirectoryEntry: Decodable, Equatable, Identifiable, Sendable { /// Kind of a symbolic link's target; `nil` for other entries and /// for dangling links. let symlinkTargetKind: String? + let size: Int64? + /// Modification time in milliseconds since the Unix epoch. + let modifiedMs: Int64? + + init(kind: String, symlinkTargetKind: String? = nil, size: Int64? = nil, modifiedMs: Int64? = nil) { + self.kind = kind + self.symlinkTargetKind = symlinkTargetKind + self.size = size + self.modifiedMs = modifiedMs + } } let name: String diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift new file mode 100644 index 000000000..ef8ace4a8 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift @@ -0,0 +1,46 @@ +import Foundation + +/// One row of the Open panel's folder list. +struct FolderListingEntry: Identifiable, Equatable, Sendable { + let name: String + let isDirectory: Bool + /// Bytes of a file; `nil` for folders and when unknown. + var byteCount: Int64? + var modified: Date? + var id: String { name } +} + +@MainActor +extension AppModel { + /// The home folder, where the Open panel starts browsing this Mac. + var localHomeFolderPath: String { services.fileStorage.homeDirectory().path } + + /// The visible entries of a local folder, folders first; `nil` when `path` + /// is not a readable folder. Large or slow folders are read off the main + /// actor, so the panel keeps responding while they load. + func localFolderEntries(at path: String) async -> [FolderListingEntry]? { + let storage = services.fileStorage + return await Task.detached(priority: .userInitiated) { + Self.readLocalFolder(at: path, storage: storage) + }.value + } + + private nonisolated static func readLocalFolder(at path: String, storage: any FileStorage) -> [FolderListingEntry]? { + let url = URL(fileURLWithPath: path, isDirectory: true) + guard storage.metadata(for: url)?.isDirectory == true else { return nil } + return storage.listDirectory(at: url) + .map { url in + let metadata = storage.metadata(for: url) + let isDirectory = metadata?.isDirectory == true + return FolderListingEntry( + name: url.lastPathComponent, isDirectory: isDirectory, + byteCount: isDirectory ? nil : metadata?.byteCount.map(Int64.init), + modified: metadata?.modificationDate + ) + } + .sorted { left, right in + if left.isDirectory != right.isDirectory { return left.isDirectory } + return left.name.localizedStandardCompare(right.name) == .orderedAscending + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift index 3869f7fdc..6abb8b45b 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift @@ -20,6 +20,11 @@ struct RemoteConnectionServices { extension AppModel { var canManageRemoteConnections: Bool { services.remoteConnections != nil } + var hasSavedRemoteConnections: Bool { + guard let services = services.remoteConnections else { return false } + return !services.store.load().isEmpty + } + func showRemoteConnections() { guard canManageRemoteConnections else { return } workbenchFeature.remoteReconnect = nil @@ -83,6 +88,7 @@ extension AppModel { root: root, remoteRoot: folder, sessionID: session.id, connectionID: connection.id )) workbenchFeature.isRemoteConnectionsPresented = false + workbenchFeature.isOpenTargetChooserPresented = false workbenchFeature.remoteReconnect = nil openProject(root) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 4b850a53e..d61fb24aa 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -849,7 +849,18 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { gitReferences.first(where: \.isCurrent) } + /// The Open action. With saved SSH connections it first asks whether the + /// folder is on this Mac or on a remote host; otherwise it goes straight + /// to the local folder chooser. func chooseProject() { + if hasSavedRemoteConnections { + workbenchFeature.isOpenTargetChooserPresented = true + } else { + chooseLocalProject() + } + } + + func chooseLocalProject() { chooseProject(title: L10n.string("Open a project"), prompt: "Open") } diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 10e851428..a29e9b325 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -322,12 +322,21 @@ private struct ActiveSessionChrome: View { NewProjectSheet(feature: session.makeNewProjectFeature()!) } } + .sheet(isPresented: Binding( + get: { session.workbenchFeature.isOpenTargetChooserPresented }, + set: { session.workbenchFeature.isOpenTargetChooserPresented = $0 } + )) { + if session.canManageRemoteConnections { + OpenProjectPanelSheet(feature: session.makeRemoteConnectionsFeature()!) + .environmentObject(session) + } + } .sheet(isPresented: Binding( get: { session.workbenchFeature.isRemoteConnectionsPresented }, set: { session.workbenchFeature.isRemoteConnectionsPresented = $0 } )) { - if let feature = session.makeRemoteConnectionsFeature() { - RemoteConnectionsSheet(feature: feature) + if session.canManageRemoteConnections { + RemoteConnectionsSheet(feature: session.makeRemoteConnectionsFeature()!) } } .sheet(isPresented: Binding( diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index bb4e66265..faf93607b 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -201,15 +201,20 @@ struct LitheSettingsPill: View { private let text: Text private let kind: Kind + /// A long value wraps onto more lines instead of keeping its full width, + /// so it cannot push the row past its container. + private let wraps: Bool init(_ title: LocalizedStringKey, kind: Kind = .neutral) { self.text = Text(title) self.kind = kind + self.wraps = false } - init(verbatim title: String, kind: Kind = .neutral) { + init(verbatim title: String, kind: Kind = .neutral, wraps: Bool = false) { self.text = Text(verbatim: title) self.kind = kind + self.wraps = wraps } var body: some View { @@ -217,12 +222,15 @@ struct LitheSettingsPill: View { .font(kind == .value ? LitheTheme.uiFont(size: 10.5, design: .monospaced) : LitheTheme.uiFont(size: 10.5)) - .lineLimit(1) + .lineLimit(wraps ? nil : 1) + .multilineTextAlignment(.leading) .foregroundStyle(foreground) .padding(.horizontal, 8) .padding(.vertical, 2) - .background(Capsule().fill(background)) - .fixedSize() + .background( + RoundedRectangle(cornerRadius: wraps ? 10 : 100, style: .continuous).fill(background) + ) + .modifier(PillSizing(wraps: wraps)) } private var foreground: Color { @@ -244,6 +252,18 @@ struct LitheSettingsPill: View { } } +private struct PillSizing: ViewModifier { + let wraps: Bool + + func body(content: Content) -> some View { + if wraps { + content.fixedSize(horizontal: false, vertical: true) + } else { + content.fixedSize() + } + } +} + /// A quiet heading inside a card that splits its rows into named groups. struct LitheSettingsSubheading: View { private let title: Text diff --git a/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift new file mode 100644 index 000000000..76061ca9d --- /dev/null +++ b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift @@ -0,0 +1,191 @@ +import LitheModuleAPI +import SwiftUI + +// Note: SSH 连接配置与测试连接见 .agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md + +/// Owns the panel's state while the sheet is shown, so a re-render of the +/// presenting view does not drop the open connection. +struct OpenProjectPanelSheet: View { + @StateObject private var feature: RemoteConnectionsFeatureModel + + init(feature: @autoclosure @escaping () -> RemoteConnectionsFeatureModel) { + _feature = StateObject(wrappedValue: feature()) + } + + var body: some View { + OpenProjectPanel(feature: feature) + } +} + +/// Open, when SSH connections are saved: one panel to pick the folder, on this +/// Mac or on a saved SSH host. Saved connections are only listed here; they +/// are added and edited in the Remote Development window. +struct OpenProjectPanel: View { + @ObservedObject var feature: RemoteConnectionsFeatureModel + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + @State private var localPath = "" + @State private var localEntries: [FolderListingEntry] = [] + @State private var localError: String? + @State private var localIsLoading = false + @State private var localTask: Task? + + private static let sidebarWidth: CGFloat = 168 + private static let localID = "local" + + /// Fixed when the panel opens; see `RemoteConnectionsView.sheetSize`. + @State private var size = ParentWindowFit.size( + preferred: CGSize(width: 920, height: 600), minimum: CGSize(width: 560, height: 380) + ) + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + sidebar.frame(width: Self.sidebarWidth) + Rectangle().fill(LitheTheme.divider).frame(width: 1) + detail + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + Rectangle().fill(LitheTheme.divider).frame(height: 1) + footer + } + .frame(width: size.width, height: size.height) + .background(LitheTheme.window) + .sheet(item: Binding(get: { feature.prompts.first }, set: { _ in })) { prompt in + RemoteAuthenticationView(prompt: prompt) { feature.answerPrompt($0) } + .interactiveDismissDisabled() + } + .onAppear { + feature.selectedID = nil + listLocal(model.localHomeFolderPath) + } + .onChange(of: feature.selectedID) { id in + feature.closeBrowser() + feature.cancelTest() + if id != nil { feature.browseRemoteFolder() } + } + .onDisappear { + localTask?.cancel() + feature.close() + } + } + + // MARK: Sidebar + + private var sidebar: some View { + LitheTreeList(nodes: sidebarNodes, selection: Binding( + get: { feature.selectedID?.uuidString ?? Self.localID }, + set: { feature.selectedID = $0.flatMap(UUID.init(uuidString:)) } + )) + .background(LitheTheme.settingsSidebar) + } + + private var sidebarNodes: [LitheTreeListNode] { + [LitheTreeListNode(id: Self.localID, onSelect: { feature.selectedID = nil }) { + HStack(spacing: 8) { + LitheIDEAIcon(resourcePath: "expui/nodes/folder.svg", size: 16, fallbackSystemImage: "folder") + Text(LocalizedStringKey("Local Folder")).lineLimit(1) + } + }, LitheTreeListNode(id: "heading-ssh", isSelectable: false) { LitheSidebarHeading("SSH") }] + + feature.connections.map { connection in + LitheTreeListNode(id: connection.id.uuidString, onSelect: { feature.selectedID = connection.id }) { + HStack(spacing: 8) { + LitheIDEAIcon(resourcePath: "expui/webReferences/server.svg", size: 16, + fallbackSystemImage: "server.rack") + Text(verbatim: connection.displayName).lineLimit(1) + } + } + } + } + + // MARK: Detail + + @ViewBuilder private var detail: some View { + if feature.selection == nil { + FolderListing( + path: localPath, parentPath: localPath == "/" ? nil : (localPath as NSString).deletingLastPathComponent, + entries: localEntries, isLoading: localIsLoading, error: localError, + go: { listLocal(($0 as NSString).expandingTildeInPath) }, + enter: { listLocal((localPath as NSString).appendingPathComponent($0)) } + ) + } else if let browser = feature.browser { + VStack(alignment: .leading, spacing: 12) { + RemoteFolderListing(feature: feature, browser: browser) + } + } else { + VStack(spacing: 10) { + Spacer() + switch feature.testState { + case .failed(let message): + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(LitheTheme.warning) + Text(verbatim: message).multilineTextAlignment(.center).textSelection(.enabled) + Button(LocalizedStringKey("Retry")) { feature.browseRemoteFolder() } + .buttonStyle(LitheActionButtonStyle()) + default: + ProgressView().controlSize(.small) + } + Spacer() + } + .font(LitheTheme.uiFont(size: 12.5)) + .frame(maxWidth: .infinity) + } + } + + // MARK: Footer + + private var footer: some View { + HStack(spacing: LitheTheme.ActionButton.spacing) { + Button(LocalizedStringKey("Remote Development")) { + // End this panel's connection before the management window opens. + feature.close() + dismiss() + DispatchQueue.main.async { model.showRemoteConnections() } + } + .buttonStyle(LitheActionButtonStyle()) + Spacer() + Button(LocalizedStringKey("Cancel")) { dismiss() } + .buttonStyle(LitheActionButtonStyle()) + .keyboardShortcut(.cancelAction) + Button(LocalizedStringKey("Open")) { + if feature.selection == nil { openLocalFolder() } else { feature.openBrowsedFolder() } + } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .keyboardShortcut(.defaultAction) + .disabled(feature.selection == nil ? localPath.isEmpty : (feature.browser?.isLoading ?? true)) + } + .padding(14) + .background(LitheTheme.toolHeader) + } + + /// Lists `path` on this Mac; a path that cannot be read keeps the previous + /// listing and shows why. A newer request replaces one still loading. + private func listLocal(_ path: String) { + guard path.hasPrefix("/") else { + localError = L10n.string("Enter an absolute path that starts with /.") + return + } + let normalized = URL(fileURLWithPath: path).standardizedFileURL.path + localTask?.cancel() + localIsLoading = true + localError = nil + localTask = Task { + let entries = await model.localFolderEntries(at: normalized) + guard !Task.isCancelled else { return } + localIsLoading = false + guard let entries else { + localError = L10n.string("This folder cannot be read.") + return + } + localPath = normalized + localEntries = entries + } + } + + private func openLocalFolder() { + let url = URL(fileURLWithPath: localPath, isDirectory: true) + dismiss() + // The sheet must be gone before the workspace replaces this window's content. + DispatchQueue.main.async { model.openProject(url) } + } +} diff --git a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift index 51d0d1dfc..777a6bef3 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -27,6 +27,11 @@ struct RemoteConnectionsView: View { private static let sidebarWidth: CGFloat = 240 private static let labelWidth: CGFloat = 120 private static let fieldWidth: CGFloat = 360 + /// Fixed when the window opens: measuring again later would measure the + /// sheet itself once it is the key window, and it would shrink on every redraw. + @State private var sheetSize = ParentWindowFit.size( + preferred: CGSize(width: 880, height: 620), minimum: CGSize(width: 640, height: 420) + ) var body: some View { VStack(spacing: 0) { @@ -46,7 +51,7 @@ struct RemoteConnectionsView: View { RemoteFolderBrowserView(feature: feature) } } - .frame(width: 880, height: 620) + .frame(width: sheetSize.width, height: sheetSize.height) .background(LitheTheme.window) .sheet(item: Binding(get: { feature.prompts.first }, set: { _ in })) { prompt in RemoteAuthenticationView(prompt: prompt) { feature.answerPrompt($0) } @@ -326,18 +331,25 @@ struct RemoteConnectionsView: View { } if let address = host.address { LitheSettingsRow("Address", info: "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up.") { - Text(verbatim: address).font(LitheTheme.uiFont(size: 12.5, design: .monospaced)).textSelection(.enabled) - if let location = host.location { - LitheSettingsPill(verbatim: Self.place(of: location), kind: .neutral) - } - } badges: { - if feature.isLocating { - ProgressView().controlSize(.small) - } else { - Button(LocalizedStringKey(host.location == nil ? "Look Up Location" : "Look Up Again")) { - feature.locateSelectedHost() + // The place can be long, so it wraps under the address and button. + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(verbatim: address) + .font(LitheTheme.uiFont(size: 12.5, design: .monospaced)) + .textSelection(.enabled) + Spacer(minLength: 0) + if feature.isLocating { + ProgressView().controlSize(.small) + } else { + Button(LocalizedStringKey(host.location == nil ? "Look Up Location" : "Look Up Again")) { + feature.locateSelectedHost() + } + .buttonStyle(LitheActionButtonStyle()) + } + } + if let location = host.location { + LitheSettingsPill(verbatim: Self.place(of: location), kind: .neutral, wraps: true) } - .buttonStyle(LitheActionButtonStyle()) } } } @@ -518,10 +530,25 @@ struct RemoteConnectionsView: View { } } +/// Shrinks a sheet's preferred size to fit inside the window presenting it. +enum ParentWindowFit { + /// Space kept around the sheet so it never reaches past its parent window. + private static let margin: CGFloat = 24 + + static func size(preferred: CGSize, minimum: CGSize) -> CGSize { + // The window the sheet opens from; a sheet that is already key reports its parent. + let window = NSApp.keyWindow.map { $0.sheetParent ?? $0 } + guard let parent = window?.contentLayoutRect.size else { return preferred } + return CGSize( + width: min(preferred.width, max(minimum.width, parent.width - margin)), + height: min(preferred.height, max(minimum.height, parent.height - margin)) + ) + } +} + /// Picks a project folder on a connected host. private struct RemoteFolderBrowserView: View { @ObservedObject var feature: RemoteConnectionsFeatureModel - @State private var pathText = "" var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -534,21 +561,7 @@ private struct RemoteFolderBrowserView: View { .font(LitheTheme.uiFont(size: 11.5)) .foregroundStyle(LitheTheme.secondaryText) } - HStack(spacing: 6) { - LitheTextField(verbatim: "/", text: $pathText) - .onSubmit { feature.openBrowserPath(pathText) } - .accessibilityLabel(Text(LocalizedStringKey("Path"))) - Button(LocalizedStringKey("Go")) { feature.openBrowserPath(pathText) } - .buttonStyle(LitheActionButtonStyle()) - } - listing(browser) - if let error = browser.error { - HStack(alignment: .top, spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(LitheTheme.warning) - Text(verbatim: error).fixedSize(horizontal: false, vertical: true) - } - .font(LitheTheme.uiFont(size: 12)) - } + RemoteFolderListing(feature: feature, browser: browser) HStack(spacing: LitheTheme.ActionButton.spacing) { if browser.session.uploaded { Text(LocalizedStringKey("The Lithe server was installed on this host.")) @@ -569,21 +582,91 @@ private struct RemoteFolderBrowserView: View { .padding(20) .frame(width: 600, height: 480) .background(LitheTheme.window) - .onAppear { pathText = feature.browser?.path ?? "" } - .onChange(of: feature.browser?.path) { pathText = $0 ?? "" } } +} + +/// The path bar, folder listing, and read error of a connected host's browser. +struct RemoteFolderListing: View { + @ObservedObject var feature: RemoteConnectionsFeatureModel + let browser: RemoteConnectionsFeatureModel.DirectoryBrowser + + var body: some View { + FolderListing( + path: browser.path, parentPath: browser.parentPath, + entries: browser.sortedEntries.map { entry in + FolderListingEntry( + name: entry.name, isDirectory: entry.isDirectory, + byteCount: entry.isDirectory ? nil : entry.metadata.size, + modified: entry.metadata.modifiedMs.map { Date(timeIntervalSince1970: Double($0) / 1000) } + ) + }, + isLoading: browser.isLoading, error: browser.error, + go: { feature.openBrowserPath($0) }, + enter: { name in + if let entry = browser.entries.first(where: { $0.name == name }) { feature.openBrowserEntry(entry) } + } + ) + } +} - private func listing(_ browser: RemoteConnectionsFeatureModel.DirectoryBrowser) -> some View { - ScrollView { - VStack(spacing: 0) { - if let parent = browser.parentPath { - entryRow(name: "..", isDirectory: true) { feature.openBrowserPath(parent) } +/// The path bar, folder list, and read error shared by the local and remote +/// folder browsers of the Open panel. +struct FolderListing: View { + let path: String + let parentPath: String? + let entries: [FolderListingEntry] + let isLoading: Bool + let error: String? + /// Lists the typed or parent path. + let go: (String) -> Void + /// Lists the folder named `name` inside the current path. + let enter: (String) -> Void + @State private var pathText = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + LitheTextField(verbatim: "/", text: $pathText) + .onSubmit { go(pathText) } + .accessibilityLabel(Text(LocalizedStringKey("Path"))) + Button(LocalizedStringKey("Go")) { go(pathText) } + .buttonStyle(LitheActionButtonStyle()) + } + listing + .overlay { + if entries.isEmpty, !isLoading, error == nil { + Text(LocalizedStringKey("This folder is empty")) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + } } - ForEach(browser.sortedEntries) { entry in - entryRow(name: entry.name, isDirectory: entry.isDirectory) { feature.openBrowserEntry(entry) } + if let error { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(LitheTheme.warning) + Text(verbatim: error).fixedSize(horizontal: false, vertical: true) } + .font(LitheTheme.uiFont(size: 12)) + } + } + .onAppear { pathText = path } + .onChange(of: path) { pathText = $0 } + } + + private var listing: some View { + VStack(spacing: 0) { + columnHeader + Rectangle().fill(LitheTheme.divider).frame(height: 1) + ScrollView { + VStack(spacing: 0) { + if let parentPath { + entryRow(FolderListingEntry(name: "..", isDirectory: true)) { go(parentPath) } + } + ForEach(entries) { entry in + entryRow(entry) { enter(entry.name) } + } + } + .padding(4) } - .padding(4) } .litheScrollViewChrome(alwaysShowVertical: false, usesCompactScrollers: true) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -591,24 +674,48 @@ private struct RemoteFolderBrowserView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay { RoundedRectangle(cornerRadius: 6).stroke(LitheTheme.divider, lineWidth: 1) - if browser.isLoading { ProgressView().controlSize(.small) } + if isLoading { ProgressView().controlSize(.small) } } } - private func entryRow(name: String, isDirectory: Bool, open: @escaping () -> Void) -> some View { - Button(action: open) { + private static let modifiedWidth: CGFloat = 112 + private static let sizeWidth: CGFloat = 64 + + private var columnHeader: some View { + HStack(spacing: 8) { + Text(LocalizedStringKey("Name")).frame(maxWidth: .infinity, alignment: .leading) + Text(LocalizedStringKey("Modified")).frame(width: Self.modifiedWidth, alignment: .leading) + Text(LocalizedStringKey("Size")).frame(width: Self.sizeWidth, alignment: .trailing) + } + .font(LitheTheme.uiFont(size: 11, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 12) + .frame(height: 24) + } + + private func entryRow(_ entry: FolderListingEntry, open: @escaping () -> Void) -> some View { + let isDirectory = entry.isDirectory + return Button(action: open) { HStack(spacing: 8) { LitheIDEAIcon( resourcePath: isDirectory ? "expui/nodes/folder.svg" : "fileTypes/text.svg", size: 16, fallbackSystemImage: isDirectory ? "folder" : "doc" ) - Text(verbatim: name) + Text(verbatim: entry.name) .font(LitheTheme.uiFont(size: 12.5)) .foregroundStyle(isDirectory ? LitheTheme.primaryText : LitheTheme.secondaryText) .lineLimit(1) - Spacer(minLength: 0) + .frame(maxWidth: .infinity, alignment: .leading) + Text(verbatim: entry.modified?.formatted(Date.FormatStyle(date: .numeric, time: .shortened)) ?? "") + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(width: Self.modifiedWidth, alignment: .leading) + Text(verbatim: entry.byteCount.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "") + .frame(width: Self.sizeWidth, alignment: .trailing) } + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) .padding(.horizontal, 8) .frame(height: 26) .contentShape(Rectangle()) @@ -619,7 +726,7 @@ private struct RemoteFolderBrowserView: View { } /// The question OpenSSH asked while testing a connection. -private struct RemoteAuthenticationView: View { +struct RemoteAuthenticationView: View { let prompt: RemoteAuthenticationPrompt let reply: (String?) -> Void @State private var answer = "" diff --git a/macos/Tests/LitheTests/RemoteConnectionsTests.swift b/macos/Tests/LitheTests/RemoteConnectionsTests.swift index 9e264f242..abdaef450 100644 --- a/macos/Tests/LitheTests/RemoteConnectionsTests.swift +++ b/macos/Tests/LitheTests/RemoteConnectionsTests.swift @@ -280,6 +280,24 @@ private func entry(_ name: String, _ kind: String) -> RemoteDirectoryEntry { RemoteDirectoryEntry(name: name, metadata: .init(kind: kind, symlinkTargetKind: nil)) } +@Suite("RemoteConnections directory entries") +struct RemoteDirectoryEntryDecodingTests { + @Test func readsSizeAndModificationTimeFromTheServerListing() throws { + let json = #"{"name":"a.txt","metadata":{"kind":"file","size":2048,"modifiedMs":1761400000000,"readonly":false,"mode":420}}"# + let entry = try JSONDecoder().decode(RemoteDirectoryEntry.self, from: Data(json.utf8)) + #expect(entry.metadata.size == 2048) + #expect(entry.metadata.modifiedMs == 1_761_400_000_000) + #expect(!entry.isDirectory) + } + + @Test func toleratesListingsWithoutSizeOrTime() throws { + let json = #"{"name":"d","metadata":{"kind":"directory"}}"# + let entry = try JSONDecoder().decode(RemoteDirectoryEntry.self, from: Data(json.utf8)) + #expect(entry.metadata.size == nil) + #expect(entry.isDirectory) + } +} + @Suite("RemoteConnections routing") struct RemoteWorkspaceRoutingTests { private let root = URL(fileURLWithPath: "/tmp/lithe-tests/RemoteWorkspaces/c/h/app", isDirectory: true) diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index b285f10ec..123e1518e 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9312,7 +9312,11 @@ "native.lastCommitSuffix": " last commit", "native.headValue": "HEAD: {arg1}", "native.resetToActualSize100": "Reset to actual size (100%)", - "native.oneUsage": "1 usage" + "native.oneUsage": "1 usage", + "native.openLocalFolder": "Local Folder", + "native.size": "Size", + "native.thisFolderCannotBeRead": "This folder cannot be read.", + "native.thisFolderIsEmpty": "This folder is empty" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 91104ef9a..653c83f08 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9312,7 +9312,11 @@ "native.lastCommitSuffix": "(上一次)", "native.headValue": "HEAD:{arg1}", "native.resetToActualSize100": "重置为实际大小(100%)", - "native.oneUsage": "1 处使用" + "native.oneUsage": "1 处使用", + "native.openLocalFolder": "本地文件夹", + "native.size": "大小", + "native.thisFolderCannotBeRead": "无法读取此文件夹。", + "native.thisFolderIsEmpty": "此文件夹为空" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 851e677ce..7f3af48db 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88358,6 +88358,50 @@ "key": "1 usage", "arguments": [] } + }, + { + "id": "native.openLocalFolder", + "description": "Native UI: Local Folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local Folder", + "arguments": [] + } + }, + { + "id": "native.size", + "description": "Native UI: Size", + "platforms": [ + "macos" + ], + "macos": { + "key": "Size", + "arguments": [] + } + }, + { + "id": "native.thisFolderCannotBeRead", + "description": "Native UI: This folder cannot be read.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This folder cannot be read.", + "arguments": [] + } + }, + { + "id": "native.thisFolderIsEmpty", + "description": "Native UI: This folder is empty", + "platforms": [ + "macos" + ], + "macos": { + "key": "This folder is empty", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/remote-development-workspace.json b/shared/platform-feature-matrix/features/remote-development-workspace.json index 71a9f5c0b..64d144cec 100644 --- a/shared/platform-feature-matrix/features/remote-development-workspace.json +++ b/shared/platform-feature-matrix/features/remote-development-workspace.json @@ -17,7 +17,9 @@ "shared/contracts/remote-workspace-protocol-v1.md", ".agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md", ".agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md", - ".agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md" + ".agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md", + "macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift", + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -30,5 +32,5 @@ "verificationStatus": "not-applicable" }, "owner": "Workspace", - "verification": "欢迎页点“远程开发”:新建一条 Linux SSH 连接(分别试 SSH Agent、私钥和密码,密码主机再试一次 2FA),测试连接后核对主机信息卡片,手动查询地区;点“打开”后文件树列出远端目录,打开、编辑并保存一个文件,在远端用其他方式修改同一文件后再次保存应提示冲突。远程 Mac、容器、Git、搜索、终端、构建和文件监听尚未接入远程工作区。Rust 后端由 scripts/verify-remote-workspace.sh 验证。" + "verification": "欢迎页点“远程开发”:新建一条 Linux SSH 连接(分别试 SSH Agent、私钥和密码,密码主机再试一次 2FA),测试连接后核对主机信息卡片,手动查询地区;点“打开”后文件树列出远端目录,打开、编辑并保存一个文件,在远端用其他方式修改同一文件后再次保存应提示冲突。远程 Mac、容器、Git、搜索、终端、构建和文件监听尚未接入远程工作区。Rust 后端由 scripts/verify-remote-workspace.sh 验证。 远程开发窗口与 Open 面板的尺寸随父窗口缩小(远程开发最小 640×420),在 800×650 窗口中确认主机卡片的地址行分两行显示、位置信息换行完整、架构标签与底部按钮不被裁掉,连续编辑字段尺寸不再变化。" } diff --git a/shared/platform-feature-matrix/features/workspace-open-switch.json b/shared/platform-feature-matrix/features/workspace-open-switch.json index 9ea220001..3b3fefd19 100644 --- a/shared/platform-feature-matrix/features/workspace-open-switch.json +++ b/shared/platform-feature-matrix/features/workspace-open-switch.json @@ -13,7 +13,9 @@ "macos/Tests/LitheTests/ProjectSwitcherBranchTests.swift", "macos/Tests/LitheTests/ContextMenuCoverageTests.swift", "macos/Sources/Lithe/Views/Workspace/OpenProjectLocationDialog.swift", - "macos/Sources/Lithe/Views/Components/LitheActionButtonStyle.swift" + "macos/Sources/Lithe/Views/Components/LitheActionButtonStyle.swift", + "macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -27,5 +29,5 @@ "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确;macOS 重启并恢复上次在 Search 等其他侧栏打开的工作区时,默认显示 Project 侧栏,打开的编辑器文件仍恢复。 macOS 欢迎页应用名、Projects、普通项目名及 New Project/Open/Clone Repository 按钮使用 Inter Regular 常规字重,与 IDEA WelcomeScreenComponentFactory 的 Font.PLAIN 和项目默认标签一致;搜索提示与项目路径保持常规字重。 打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。Windows 端询问弹窗提供 This Window(替换当前项目)/ New Window / Attach / Cancel 四个选项,勾选\"不再询问\"后记住所选目的地(设置项 projectOpenDefaultDestination),替换前对未保存缓冲区只弹一次确认,确认后原项目即锁定为关闭中(不可切回编辑),新项目打开失败时回滚保留原项目;关闭项目时若后继项目切换失败则中止关闭并保留原项目。 macOS 项目切换菜单按名称、路径和当前分支内容测量宽度,仅受屏幕可见区域限制;打开项目和最近项目中的 Git 仓库在路径下显示分支图标与名称,普通目录不显示该行。外部切换分支后重新打开菜单读取最新名称,元数据读取不覆盖 Git 身份草稿;检查明暗主题和长路径。 macOS 项目打开位置提示使用带标题的 LitheCenteredPopup、共享勾选框和统一按钮;在明暗主题及中英文验证长项目名换行、统一悬停底色、默认动作 Return、取消 Escape/关闭窗口、不再询问持久化、新窗口和此窗口打开。仅请求所属窗口显示,旧请求取消不得影响新请求,父窗口输入在提示期间被阻止。 Windows 端点击路径已不存在(或已不是文件夹)的最近项目时,弹出“Recent Project Unavailable”确认框,选择“Remove From List”会把该项从最近项目移除,取消则保留并标记为缺失;标题栏项目菜单的最近项目行悬浮显示移除按钮(聚焦行按 Delete/Backspace 同样移除),缺失项目头像和文字灰显且移除按钮常驻,点击移除不会打开项目也不会关闭菜单。" + "verification": "打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确;macOS 重启并恢复上次在 Search 等其他侧栏打开的工作区时,默认显示 Project 侧栏,打开的编辑器文件仍恢复。 macOS 欢迎页应用名、Projects、普通项目名及 New Project/Open/Clone Repository 按钮使用 Inter Regular 常规字重,与 IDEA WelcomeScreenComponentFactory 的 Font.PLAIN 和项目默认标签一致;搜索提示与项目路径保持常规字重。 打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。Windows 端询问弹窗提供 This Window(替换当前项目)/ New Window / Attach / Cancel 四个选项,勾选\"不再询问\"后记住所选目的地(设置项 projectOpenDefaultDestination),替换前对未保存缓冲区只弹一次确认,确认后原项目即锁定为关闭中(不可切回编辑),新项目打开失败时回滚保留原项目;关闭项目时若后继项目切换失败则中止关闭并保留原项目。 macOS 项目切换菜单按名称、路径和当前分支内容测量宽度,仅受屏幕可见区域限制;打开项目和最近项目中的 Git 仓库在路径下显示分支图标与名称,普通目录不显示该行。外部切换分支后重新打开菜单读取最新名称,元数据读取不覆盖 Git 身份草稿;检查明暗主题和长路径。 macOS 项目打开位置提示使用带标题的 LitheCenteredPopup、共享勾选框和统一按钮;在明暗主题及中英文验证长项目名换行、统一悬停底色、默认动作 Return、取消 Escape/关闭窗口、不再询问持久化、新窗口和此窗口打开。仅请求所属窗口显示,旧请求取消不得影响新请求,父窗口输入在提示期间被阻止。 Windows 端点击路径已不存在(或已不是文件夹)的最近项目时,弹出“Recent Project Unavailable”确认框,选择“Remove From List”会把该项从最近项目移除,取消则保留并标记为缺失;标题栏项目菜单的最近项目行悬浮显示移除按钮(聚焦行按 Delete/Backspace 同样移除),缺失项目头像和文字灰显且移除按钮常驻,点击移除不会打开项目也不会关闭菜单。 macOS 已保存 SSH 连接时,Open(欢迎页、菜单 Open Project…、项目切换器)弹出统一选择面板:左侧「本地文件夹」与各 SSH 连接,右侧在面板内浏览目录并显示修改时间与大小,选 SSH 主机时自动连接并浏览远程目录,点 Open 在当前窗口打开所选目录;没有保存连接时直接弹系统选择面板。在 800×650 的小窗口和大窗口验证面板完整显示、连续点击后尺寸不变,进入桌面等受保护文件夹时系统授权弹窗出现一次,Esc 取消,左下角“远程开发”进入连接管理。" }