From 864c822091486d5924952902f14230a070673309 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 21:46:30 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(macos):=20=E6=89=93=E5=BC=80=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=97=B6=E6=8C=89=E5=B7=B2=E4=BF=9D=E5=AD=98=E7=9A=84?= =?UTF-8?q?=20SSH=20=E8=BF=9E=E6=8E=A5=E9=80=89=E6=8B=A9=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=88=96=E8=BF=9C=E7=A8=8B=EF=BC=8C=E5=B9=B6=E8=AE=A9=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E5=BC=80=E5=8F=91=E7=AA=97=E5=8F=A3=E9=9A=8F=E7=88=B6?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E7=BC=A9=E6=94=BE=20(#1487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Resources/en.lproj/Localizable.strings | 6 ++ .../zh-Hans.lproj/Localizable.strings | 6 ++ .../Features/WorkbenchFeatureModel.swift | 3 + .../AppModel/AppModel+RemoteConnections.swift | 5 ++ .../Lithe/Models/AppModel/AppModel.swift | 11 ++++ macos/Sources/Lithe/Views/App/RootView.swift | 7 +++ .../Workspace/OpenTargetChooserView.swift | 61 +++++++++++++++++++ .../Workspace/RemoteConnectionsView.swift | 17 +++++- shared/i18n/locales/en-US.json | 5 +- shared/i18n/locales/zh-CN.json | 5 +- shared/i18n/messages.json | 33 ++++++++++ 11 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Workspace/OpenTargetChooserView.swift diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index e6c790365c..5bccfd62f7 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…"; @@ -2426,6 +2427,8 @@ "Open a Git project before using pull requests" = "Open a Git project before using pull requests"; "Open a Java file" = "Open a Java file"; "Open a Java file before starting Debug" = "Open a Java file before starting Debug"; +"Open a folder on a saved SSH host" = "Open a folder on a saved SSH host"; +"Open a folder on this Mac" = "Open a folder on this Mac"; "Open a local folder to start working." = "Open a local folder to start working."; "Open a local project folder" = "Open a local project folder"; "Open a project" = "Open a project"; @@ -6852,6 +6855,8 @@ "native.openInLithe" = "Open in Lithe"; "native.openInNewWindow" = "Open in New Window"; "native.openInRightSplit" = "Open in Right Split"; +"native.openLocalFolder" = "Local Folder"; +"native.openLocalFolderDetail" = "Open a folder on this Mac"; "native.openLogDirectory" = "Open Log Directory"; "native.openMarketplace" = "Open Marketplace"; "native.openModulePomXml" = "Open module pom.xml"; @@ -6868,6 +6873,7 @@ "native.openReleasePage" = "Open Release Page"; "native.openRemote" = "Open Remote ↗"; "native.openRemote.variant2" = "Open remote"; +"native.openRemoteFolderDetail" = "Open a folder on a saved SSH host"; "native.openRunConfiguration" = "Open Run Configuration"; "native.openServiceInBrowser" = "Open service in browser"; "native.openSkillMd" = "Open SKILL.md"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index fce62f8a8a..47eeecb100 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…" = "本地历史…"; @@ -2426,6 +2427,8 @@ "Open a Git project before using pull requests" = "请先打开 Git 项目,再使用拉取请求功能"; "Open a Java file" = "打开 Java 文件"; "Open a Java file before starting Debug" = "开始调试前请先打开 Java 文件"; +"Open a folder on a saved SSH host" = "打开已保存的 SSH 主机上的文件夹"; +"Open a folder on this Mac" = "打开这台 Mac 上的文件夹"; "Open a local folder to start working." = "打开本地文件夹开始工作。"; "Open a local project folder" = "打开本地项目文件夹"; "Open a project" = "打开项目"; @@ -6852,6 +6855,8 @@ "native.openInLithe" = "在 Lithe 中打开"; "native.openInNewWindow" = "在新窗口中打开"; "native.openInRightSplit" = "在右侧分栏打开"; +"native.openLocalFolder" = "本地文件夹"; +"native.openLocalFolderDetail" = "打开这台 Mac 上的文件夹"; "native.openLogDirectory" = "打开日志目录"; "native.openMarketplace" = "打开插件市场"; "native.openModulePomXml" = "打开所属模块的 pom.xml"; @@ -6868,6 +6873,7 @@ "native.openReleasePage" = "打开 Release 页面"; "native.openRemote" = "打开远程仓库 ↗"; "native.openRemote.variant2" = "打开远程仓库"; +"native.openRemoteFolderDetail" = "打开已保存的 SSH 主机上的文件夹"; "native.openRunConfiguration" = "打开运行配置"; "native.openServiceInBrowser" = "在浏览器中打开服务"; "native.openSkillMd" = "打开 SKILL.md"; diff --git a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift index e81774a0ea..af0a388411 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/Models/AppModel/AppModel+RemoteConnections.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift index 3869f7fdc3..12492e5a81 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 diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 4b850a53ef..d61fb24aa2 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 10e8514286..8103c7a8ec 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -322,6 +322,13 @@ private struct ActiveSessionChrome: View { NewProjectSheet(feature: session.makeNewProjectFeature()!) } } + .sheet(isPresented: Binding( + get: { session.workbenchFeature.isOpenTargetChooserPresented }, + set: { session.workbenchFeature.isOpenTargetChooserPresented = $0 } + )) { + OpenTargetChooserView() + .environmentObject(session) + } .sheet(isPresented: Binding( get: { session.workbenchFeature.isRemoteConnectionsPresented }, set: { session.workbenchFeature.isRemoteConnectionsPresented = $0 } diff --git a/macos/Sources/Lithe/Views/Workspace/OpenTargetChooserView.swift b/macos/Sources/Lithe/Views/Workspace/OpenTargetChooserView.swift new file mode 100644 index 0000000000..422934c5ed --- /dev/null +++ b/macos/Sources/Lithe/Views/Workspace/OpenTargetChooserView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +/// Shown by Open when SSH connections are saved: the project is either a +/// folder on this Mac or a folder on one of those hosts. +struct OpenTargetChooserView: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Open Project") + .font(LitheTheme.uiFont(size: 15, weight: .semibold)) + + VStack(spacing: 8) { + target(title: "Local Folder", detail: "Open a folder on this Mac", systemImage: "folder") { + dismiss() + // The sheet must be gone before the panel opens. + DispatchQueue.main.async { model.chooseLocalProject() } + } + target(title: "Remote Development", detail: "Open a folder on a saved SSH host", + systemImage: "server.rack") { + dismiss() + DispatchQueue.main.async { model.showRemoteConnections() } + } + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + .buttonStyle(LitheActionButtonStyle()) + } + } + .padding(20) + .frame(width: 360) + .background(LitheTheme.window) + } + + private func target( + title: LocalizedStringKey, detail: LocalizedStringKey, systemImage: String, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(LitheTheme.uiFont(size: 18, weight: .regular)) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(LitheTheme.uiFont(size: 13, weight: .medium)) + Text(detail).font(LitheTheme.uiFont(size: 11)).foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(RoundedRectangle(cornerRadius: 6).fill(LitheTheme.settingsSidebar)) + } +} diff --git a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift index 51d0d1dfca..ad93211e42 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -27,6 +27,21 @@ struct RemoteConnectionsView: View { private static let sidebarWidth: CGFloat = 240 private static let labelWidth: CGFloat = 120 private static let fieldWidth: CGFloat = 360 + private static let preferredSize = CGSize(width: 880, height: 620) + private static let minimumSize = CGSize(width: 640, height: 420) + /// Space kept around the sheet so it never reaches past its parent window. + private static let windowMargin: CGFloat = 40 + + /// The size the window takes: the preferred size, shrunk to fit inside the + /// window presenting it. The detail pane scrolls, so a smaller window only + /// shortens the form while the footer buttons stay in view. + private var sheetSize: CGSize { + let parent = NSApp.keyWindow?.contentLayoutRect.size ?? Self.preferredSize + return CGSize( + width: min(Self.preferredSize.width, max(Self.minimumSize.width, parent.width - Self.windowMargin)), + height: min(Self.preferredSize.height, max(Self.minimumSize.height, parent.height - Self.windowMargin)) + ) + } var body: some View { VStack(spacing: 0) { @@ -46,7 +61,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) } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index b285f10ec7..e217469c6a 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9312,7 +9312,10 @@ "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.openLocalFolderDetail": "Open a folder on this Mac", + "native.openRemoteFolderDetail": "Open a folder on a saved SSH host" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 91104ef9ae..fcbb29897b 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9312,7 +9312,10 @@ "native.lastCommitSuffix": "(上一次)", "native.headValue": "HEAD:{arg1}", "native.resetToActualSize100": "重置为实际大小(100%)", - "native.oneUsage": "1 处使用" + "native.oneUsage": "1 处使用", + "native.openLocalFolder": "本地文件夹", + "native.openLocalFolderDetail": "打开这台 Mac 上的文件夹", + "native.openRemoteFolderDetail": "打开已保存的 SSH 主机上的文件夹" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 851e677cec..f058c23d5b 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88358,6 +88358,39 @@ "key": "1 usage", "arguments": [] } + }, + { + "id": "native.openLocalFolder", + "description": "Native UI: Local Folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local Folder", + "arguments": [] + } + }, + { + "id": "native.openLocalFolderDetail", + "description": "Native UI: Open a folder on this Mac", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a folder on this Mac", + "arguments": [] + } + }, + { + "id": "native.openRemoteFolderDetail", + "description": "Native UI: Open a folder on a saved SSH host", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a folder on a saved SSH host", + "arguments": [] + } } ] } From 32da3e6b6f6297be5eb71f4936074312e912616f Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 21:58:11 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(macos):=20=E5=B7=B2=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=20SSH=20=E8=BF=9E=E6=8E=A5=E6=97=B6=EF=BC=8C=E6=89=93=E5=BC=80?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=94=B9=E4=B8=BA=E7=BB=9F=E4=B8=80=E7=9A=84?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0/=E8=BF=9C=E7=A8=8B=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20(#1487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Resources/en.lproj/Localizable.strings | 2 - .../zh-Hans.lproj/Localizable.strings | 2 - .../RemoteConnectionsFeatureModel.swift | 16 ++ .../AppModel/AppModel+RemoteConnections.swift | 1 + macos/Sources/Lithe/Views/App/RootView.swift | 6 +- .../Views/Workspace/OpenProjectPanel.swift | 155 ++++++++++++++++++ .../Workspace/OpenTargetChooserView.swift | 61 ------- .../Workspace/RemoteConnectionsView.swift | 78 +++++---- shared/i18n/locales/en-US.json | 3 +- shared/i18n/locales/zh-CN.json | 3 +- shared/i18n/messages.json | 11 -- 11 files changed, 224 insertions(+), 114 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift delete mode 100644 macos/Sources/Lithe/Views/Workspace/OpenTargetChooserView.swift diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 5bccfd62f7..0d08c43265 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2427,7 +2427,6 @@ "Open a Git project before using pull requests" = "Open a Git project before using pull requests"; "Open a Java file" = "Open a Java file"; "Open a Java file before starting Debug" = "Open a Java file before starting Debug"; -"Open a folder on a saved SSH host" = "Open a folder on a saved SSH host"; "Open a folder on this Mac" = "Open a folder on this Mac"; "Open a local folder to start working." = "Open a local folder to start working."; "Open a local project folder" = "Open a local project folder"; @@ -6873,7 +6872,6 @@ "native.openReleasePage" = "Open Release Page"; "native.openRemote" = "Open Remote ↗"; "native.openRemote.variant2" = "Open remote"; -"native.openRemoteFolderDetail" = "Open a folder on a saved SSH host"; "native.openRunConfiguration" = "Open Run Configuration"; "native.openServiceInBrowser" = "Open service in browser"; "native.openSkillMd" = "Open SKILL.md"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 47eeecb100..b52be07fb2 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2427,7 +2427,6 @@ "Open a Git project before using pull requests" = "请先打开 Git 项目,再使用拉取请求功能"; "Open a Java file" = "打开 Java 文件"; "Open a Java file before starting Debug" = "开始调试前请先打开 Java 文件"; -"Open a folder on a saved SSH host" = "打开已保存的 SSH 主机上的文件夹"; "Open a folder on this Mac" = "打开这台 Mac 上的文件夹"; "Open a local folder to start working." = "打开本地文件夹开始工作。"; "Open a local project folder" = "打开本地项目文件夹"; @@ -6873,7 +6872,6 @@ "native.openReleasePage" = "打开 Release 页面"; "native.openRemote" = "打开远程仓库 ↗"; "native.openRemote.variant2" = "打开远程仓库"; -"native.openRemoteFolderDetail" = "打开已保存的 SSH 主机上的文件夹"; "native.openRunConfiguration" = "打开运行配置"; "native.openServiceInBrowser" = "在浏览器中打开服务"; "native.openSkillMd" = "打开 SKILL.md"; diff --git a/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift index c9f92b5fa6..1049838590 100644 --- a/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift @@ -339,6 +339,22 @@ 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, + let connection = connections.first(where: { $0.id == browser.connectionID }) else { 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/Models/AppModel/AppModel+RemoteConnections.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift index 12492e5a81..6abb8b45b7 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift @@ -88,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/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 8103c7a8ec..9b277e8aa4 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -326,8 +326,10 @@ private struct ActiveSessionChrome: View { get: { session.workbenchFeature.isOpenTargetChooserPresented }, set: { session.workbenchFeature.isOpenTargetChooserPresented = $0 } )) { - OpenTargetChooserView() - .environmentObject(session) + if let feature = session.makeRemoteConnectionsFeature() { + OpenProjectPanelSheet(feature: feature) + .environmentObject(session) + } } .sheet(isPresented: Binding( get: { session.workbenchFeature.isRemoteConnectionsPresented }, diff --git a/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift new file mode 100644 index 0000000000..fd7fd31655 --- /dev/null +++ b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift @@ -0,0 +1,155 @@ +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 + + private static let sidebarWidth: CGFloat = 200 + private static let localID = "local" + + private var size: CGSize { + ParentWindowFit.size(preferred: CGSize(width: 760, height: 520), minimum: CGSize(width: 520, height: 360)) + } + + 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 } + .onChange(of: feature.selectedID) { id in + feature.closeBrowser() + feature.cancelTest() + if id != nil { feature.browseRemoteFolder() } + } + .onDisappear { 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 { + VStack(spacing: 12) { + Spacer() + Text(LocalizedStringKey("Open a folder on this Mac")) + .foregroundStyle(LitheTheme.secondaryText) + Button(LocalizedStringKey("Choose…")) { openLocalFolder() } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + Spacer() + } + .frame(maxWidth: .infinity) + } 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")) { + 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 && (feature.browser?.isLoading ?? true)) + } + .padding(14) + .background(LitheTheme.toolHeader) + } + + private func openLocalFolder() { + dismiss() + // The sheet must be gone before the system panel opens. + DispatchQueue.main.async { model.chooseLocalProject() } + } +} diff --git a/macos/Sources/Lithe/Views/Workspace/OpenTargetChooserView.swift b/macos/Sources/Lithe/Views/Workspace/OpenTargetChooserView.swift deleted file mode 100644 index 422934c5ed..0000000000 --- a/macos/Sources/Lithe/Views/Workspace/OpenTargetChooserView.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI - -/// Shown by Open when SSH connections are saved: the project is either a -/// folder on this Mac or a folder on one of those hosts. -struct OpenTargetChooserView: View { - @EnvironmentObject private var model: AppModel - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Open Project") - .font(LitheTheme.uiFont(size: 15, weight: .semibold)) - - VStack(spacing: 8) { - target(title: "Local Folder", detail: "Open a folder on this Mac", systemImage: "folder") { - dismiss() - // The sheet must be gone before the panel opens. - DispatchQueue.main.async { model.chooseLocalProject() } - } - target(title: "Remote Development", detail: "Open a folder on a saved SSH host", - systemImage: "server.rack") { - dismiss() - DispatchQueue.main.async { model.showRemoteConnections() } - } - } - - HStack { - Spacer() - Button("Cancel") { dismiss() } - .keyboardShortcut(.cancelAction) - .buttonStyle(LitheActionButtonStyle()) - } - } - .padding(20) - .frame(width: 360) - .background(LitheTheme.window) - } - - private func target( - title: LocalizedStringKey, detail: LocalizedStringKey, systemImage: String, action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack(spacing: 12) { - Image(systemName: systemImage) - .font(LitheTheme.uiFont(size: 18, weight: .regular)) - .frame(width: 24) - VStack(alignment: .leading, spacing: 2) { - Text(title).font(LitheTheme.uiFont(size: 13, weight: .medium)) - Text(detail).font(LitheTheme.uiFont(size: 11)).foregroundStyle(.secondary) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .background(RoundedRectangle(cornerRadius: 6).fill(LitheTheme.settingsSidebar)) - } -} diff --git a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift index ad93211e42..d0e36d877a 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -27,20 +27,8 @@ struct RemoteConnectionsView: View { private static let sidebarWidth: CGFloat = 240 private static let labelWidth: CGFloat = 120 private static let fieldWidth: CGFloat = 360 - private static let preferredSize = CGSize(width: 880, height: 620) - private static let minimumSize = CGSize(width: 640, height: 420) - /// Space kept around the sheet so it never reaches past its parent window. - private static let windowMargin: CGFloat = 40 - - /// The size the window takes: the preferred size, shrunk to fit inside the - /// window presenting it. The detail pane scrolls, so a smaller window only - /// shortens the form while the footer buttons stay in view. private var sheetSize: CGSize { - let parent = NSApp.keyWindow?.contentLayoutRect.size ?? Self.preferredSize - return CGSize( - width: min(Self.preferredSize.width, max(Self.minimumSize.width, parent.width - Self.windowMargin)), - height: min(Self.preferredSize.height, max(Self.minimumSize.height, parent.height - Self.windowMargin)) - ) + ParentWindowFit.size(preferred: CGSize(width: 880, height: 620), minimum: CGSize(width: 640, height: 420)) } var body: some View { @@ -533,10 +521,23 @@ 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 = 40 + + static func size(preferred: CGSize, minimum: CGSize) -> CGSize { + guard let parent = NSApp.keyWindow?.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) { @@ -549,21 +550,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.")) @@ -584,8 +571,35 @@ 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 + @State private var pathText = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + 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)) + } + } + .onAppear { pathText = browser.path } + .onChange(of: browser.path) { pathText = $0 } } private func listing(_ browser: RemoteConnectionsFeatureModel.DirectoryBrowser) -> some View { @@ -634,7 +648,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/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index e217469c6a..f4518f4eec 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9314,8 +9314,7 @@ "native.resetToActualSize100": "Reset to actual size (100%)", "native.oneUsage": "1 usage", "native.openLocalFolder": "Local Folder", - "native.openLocalFolderDetail": "Open a folder on this Mac", - "native.openRemoteFolderDetail": "Open a folder on a saved SSH host" + "native.openLocalFolderDetail": "Open a folder on this Mac" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index fcbb29897b..4bcbcf6e08 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9314,8 +9314,7 @@ "native.resetToActualSize100": "重置为实际大小(100%)", "native.oneUsage": "1 处使用", "native.openLocalFolder": "本地文件夹", - "native.openLocalFolderDetail": "打开这台 Mac 上的文件夹", - "native.openRemoteFolderDetail": "打开已保存的 SSH 主机上的文件夹" + "native.openLocalFolderDetail": "打开这台 Mac 上的文件夹" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index f058c23d5b..c7ebe34487 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88380,17 +88380,6 @@ "key": "Open a folder on this Mac", "arguments": [] } - }, - { - "id": "native.openRemoteFolderDetail", - "description": "Native UI: Open a folder on a saved SSH host", - "platforms": [ - "macos" - ], - "macos": { - "key": "Open a folder on a saved SSH host", - "arguments": [] - } } ] } From f499874152e16fac15c2c796f4a73874f5d29c4c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 22:06:02 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(macos):=20=E6=89=93=E5=BC=80=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=86=85=E7=BD=AE=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B9=E6=B5=8F=E8=A7=88=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=BC=B9?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E8=AE=BF=E8=BE=BE=E9=9D=A2=E6=9D=BF=20(#1487?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Resources/en.lproj/Localizable.strings | 2 - .../zh-Hans.lproj/Localizable.strings | 2 - .../AppModel/AppModel+LocalFolders.swift | 28 +++++++++++ .../Views/Workspace/OpenProjectPanel.swift | 43 +++++++++++----- .../Workspace/RemoteConnectionsView.swift | 50 ++++++++++++++----- shared/i18n/locales/en-US.json | 3 +- shared/i18n/locales/zh-CN.json | 3 +- shared/i18n/messages.json | 11 ---- 8 files changed, 98 insertions(+), 44 deletions(-) create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 0d08c43265..83e66d890d 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2427,7 +2427,6 @@ "Open a Git project before using pull requests" = "Open a Git project before using pull requests"; "Open a Java file" = "Open a Java file"; "Open a Java file before starting Debug" = "Open a Java file before starting Debug"; -"Open a folder on this Mac" = "Open a folder on this Mac"; "Open a local folder to start working." = "Open a local folder to start working."; "Open a local project folder" = "Open a local project folder"; "Open a project" = "Open a project"; @@ -6855,7 +6854,6 @@ "native.openInNewWindow" = "Open in New Window"; "native.openInRightSplit" = "Open in Right Split"; "native.openLocalFolder" = "Local Folder"; -"native.openLocalFolderDetail" = "Open a folder on this Mac"; "native.openLogDirectory" = "Open Log Directory"; "native.openMarketplace" = "Open Marketplace"; "native.openModulePomXml" = "Open module pom.xml"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index b52be07fb2..b5a6419442 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2427,7 +2427,6 @@ "Open a Git project before using pull requests" = "请先打开 Git 项目,再使用拉取请求功能"; "Open a Java file" = "打开 Java 文件"; "Open a Java file before starting Debug" = "开始调试前请先打开 Java 文件"; -"Open a folder on this Mac" = "打开这台 Mac 上的文件夹"; "Open a local folder to start working." = "打开本地文件夹开始工作。"; "Open a local project folder" = "打开本地项目文件夹"; "Open a project" = "打开项目"; @@ -6855,7 +6854,6 @@ "native.openInNewWindow" = "在新窗口中打开"; "native.openInRightSplit" = "在右侧分栏打开"; "native.openLocalFolder" = "本地文件夹"; -"native.openLocalFolderDetail" = "打开这台 Mac 上的文件夹"; "native.openLogDirectory" = "打开日志目录"; "native.openMarketplace" = "打开插件市场"; "native.openModulePomXml" = "打开所属模块的 pom.xml"; 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 0000000000..c253cea2a4 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift @@ -0,0 +1,28 @@ +import Foundation + +/// One row of the Open panel's folder list. +struct FolderListingEntry: Identifiable, Equatable { + let name: String + let isDirectory: Bool + 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. + func localFolderEntries(at path: String) -> [FolderListingEntry]? { + let storage = services.fileStorage + let url = URL(fileURLWithPath: path, isDirectory: true) + guard storage.metadata(for: url)?.isDirectory == true else { return nil } + return storage.listDirectory(at: url) + .map { FolderListingEntry(name: $0.lastPathComponent, isDirectory: storage.metadata(for: $0)?.isDirectory == true) } + .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/Views/Workspace/OpenProjectPanel.swift b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift index fd7fd31655..7957dd0409 100644 --- a/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift +++ b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift @@ -24,6 +24,9 @@ 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? private static let sidebarWidth: CGFloat = 200 private static let localID = "local" @@ -50,7 +53,10 @@ struct OpenProjectPanel: View { RemoteAuthenticationView(prompt: prompt) { feature.answerPrompt($0) } .interactiveDismissDisabled() } - .onAppear { feature.selectedID = nil } + .onAppear { + feature.selectedID = nil + listLocal(model.localHomeFolderPath) + } .onChange(of: feature.selectedID) { id in feature.closeBrowser() feature.cancelTest() @@ -91,15 +97,12 @@ struct OpenProjectPanel: View { @ViewBuilder private var detail: some View { if feature.selection == nil { - VStack(spacing: 12) { - Spacer() - Text(LocalizedStringKey("Open a folder on this Mac")) - .foregroundStyle(LitheTheme.secondaryText) - Button(LocalizedStringKey("Choose…")) { openLocalFolder() } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - Spacer() - } - .frame(maxWidth: .infinity) + FolderListing( + path: localPath, parentPath: localPath == "/" ? nil : (localPath as NSString).deletingLastPathComponent, + entries: localEntries, isLoading: false, 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) @@ -141,15 +144,29 @@ struct OpenProjectPanel: View { } .buttonStyle(LitheActionButtonStyle(isPrimary: true)) .keyboardShortcut(.defaultAction) - .disabled(feature.selection != nil && (feature.browser?.isLoading ?? true)) + .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. + private func listLocal(_ path: String) { + let normalized = URL(fileURLWithPath: path).standardizedFileURL.path + guard let entries = model.localFolderEntries(at: normalized) else { + localError = L10n.string("Enter an absolute path that starts with /.") + return + } + localPath = normalized + localEntries = entries + localError = nil + } + private func openLocalFolder() { + let url = URL(fileURLWithPath: localPath, isDirectory: true) dismiss() - // The sheet must be gone before the system panel opens. - DispatchQueue.main.async { model.chooseLocalProject() } + // 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 d0e36d877a..62c99957a3 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -578,19 +578,45 @@ private struct RemoteFolderBrowserView: View { 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 { FolderListingEntry(name: $0.name, isDirectory: $0.isDirectory) }, + 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) } + } + ) + } +} + +/// 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 { feature.openBrowserPath(pathText) } + .onSubmit { go(pathText) } .accessibilityLabel(Text(LocalizedStringKey("Path"))) - Button(LocalizedStringKey("Go")) { feature.openBrowserPath(pathText) } + Button(LocalizedStringKey("Go")) { go(pathText) } .buttonStyle(LitheActionButtonStyle()) } - listing(browser) - if let error = browser.error { + listing + if let error { HStack(alignment: .top, spacing: 6) { Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(LitheTheme.warning) Text(verbatim: error).fixedSize(horizontal: false, vertical: true) @@ -598,18 +624,18 @@ struct RemoteFolderListing: View { .font(LitheTheme.uiFont(size: 12)) } } - .onAppear { pathText = browser.path } - .onChange(of: browser.path) { pathText = $0 } + .onAppear { pathText = path } + .onChange(of: path) { pathText = $0 } } - private func listing(_ browser: RemoteConnectionsFeatureModel.DirectoryBrowser) -> some View { + private var listing: some View { ScrollView { VStack(spacing: 0) { - if let parent = browser.parentPath { - entryRow(name: "..", isDirectory: true) { feature.openBrowserPath(parent) } + if let parentPath { + entryRow(name: "..", isDirectory: true) { go(parentPath) } } - ForEach(browser.sortedEntries) { entry in - entryRow(name: entry.name, isDirectory: entry.isDirectory) { feature.openBrowserEntry(entry) } + ForEach(entries) { entry in + entryRow(name: entry.name, isDirectory: entry.isDirectory) { enter(entry.name) } } } .padding(4) @@ -620,7 +646,7 @@ struct RemoteFolderListing: 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) } } } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index f4518f4eec..3fd3e252a1 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9313,8 +9313,7 @@ "native.headValue": "HEAD: {arg1}", "native.resetToActualSize100": "Reset to actual size (100%)", "native.oneUsage": "1 usage", - "native.openLocalFolder": "Local Folder", - "native.openLocalFolderDetail": "Open a folder on this Mac" + "native.openLocalFolder": "Local Folder" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 4bcbcf6e08..c09571204e 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9313,8 +9313,7 @@ "native.headValue": "HEAD:{arg1}", "native.resetToActualSize100": "重置为实际大小(100%)", "native.oneUsage": "1 处使用", - "native.openLocalFolder": "本地文件夹", - "native.openLocalFolderDetail": "打开这台 Mac 上的文件夹" + "native.openLocalFolder": "本地文件夹" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index c7ebe34487..90c387cb0e 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88369,17 +88369,6 @@ "key": "Local Folder", "arguments": [] } - }, - { - "id": "native.openLocalFolderDetail", - "description": "Native UI: Open a folder on this Mac", - "platforms": [ - "macos" - ], - "macos": { - "key": "Open a folder on this Mac", - "arguments": [] - } } ] } From 0c1fd730c60e838ead3e9a4cb6f3d284e33d7825 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 22:12:08 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(macos):=20=E6=89=93=E5=BC=80=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=96=87=E4=BB=B6=E5=88=97=E8=A1=A8=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=97=B6=E9=97=B4=E4=B8=8E=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=B0=BA=E5=AF=B8=E6=9B=B4=E5=A4=A7?= =?UTF-8?q?=E4=B8=94=E4=B8=8D=E5=86=8D=E9=9A=8F=E7=82=B9=E5=87=BB=E7=BC=A9?= =?UTF-8?q?=E5=B0=8F=20(#1487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Core/Ports/RemoteConnectionProbing.swift | 3 + .../AppModel/AppModel+LocalFolders.swift | 13 +++- .../Views/Workspace/OpenProjectPanel.swift | 9 +-- .../Workspace/RemoteConnectionsView.swift | 68 ++++++++++++++----- shared/i18n/locales/en-US.json | 3 +- shared/i18n/locales/zh-CN.json | 3 +- shared/i18n/messages.json | 11 +++ 9 files changed, 90 insertions(+), 24 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 83e66d890d..942f3c3189 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3336,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."; @@ -7731,6 +7732,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."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index b5a6419442..7ffdc7d897 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3336,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." = "导入的技能不能包含符号链接或特殊文件。"; @@ -7731,6 +7732,7 @@ "native.signedInThroughTheACPGatewayWithThisProviderSAPIKey" = "使用该服务商的 API 密钥通过 ACP 网关登录。"; "native.singleFile" = "单文件"; "native.singleRow" = "单行"; +"native.size" = "大小"; "native.skillActions" = "技能操作"; "native.skillFiles" = "技能文件"; "native.skillImportsCannotContainSymbolicLinksOrSpecialFiles" = "导入的技能不能包含符号链接或特殊文件。"; diff --git a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift index 22095676d7..2a593b15d6 100644 --- a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift +++ b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift @@ -167,6 +167,9 @@ 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? } let name: String diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift index c253cea2a4..d8e543c947 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift @@ -4,6 +4,9 @@ import Foundation struct FolderListingEntry: Identifiable, Equatable { 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 } } @@ -19,7 +22,15 @@ extension AppModel { let url = URL(fileURLWithPath: path, isDirectory: true) guard storage.metadata(for: url)?.isDirectory == true else { return nil } return storage.listDirectory(at: url) - .map { FolderListingEntry(name: $0.lastPathComponent, isDirectory: storage.metadata(for: $0)?.isDirectory == true) } + .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/Views/Workspace/OpenProjectPanel.swift b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift index 7957dd0409..6e369a1eb4 100644 --- a/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift +++ b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift @@ -28,12 +28,13 @@ struct OpenProjectPanel: View { @State private var localEntries: [FolderListingEntry] = [] @State private var localError: String? - private static let sidebarWidth: CGFloat = 200 + private static let sidebarWidth: CGFloat = 168 private static let localID = "local" - private var size: CGSize { - ParentWindowFit.size(preferred: CGSize(width: 760, height: 520), minimum: CGSize(width: 520, height: 360)) - } + /// 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) { diff --git a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift index 62c99957a3..0a1f207880 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -27,9 +27,11 @@ struct RemoteConnectionsView: View { private static let sidebarWidth: CGFloat = 240 private static let labelWidth: CGFloat = 120 private static let fieldWidth: CGFloat = 360 - private var sheetSize: CGSize { - ParentWindowFit.size(preferred: CGSize(width: 880, height: 620), minimum: CGSize(width: 640, height: 420)) - } + /// 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) { @@ -524,7 +526,7 @@ 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 = 40 + private static let margin: CGFloat = 24 static func size(preferred: CGSize, minimum: CGSize) -> CGSize { guard let parent = NSApp.keyWindow?.contentLayoutRect.size else { return preferred } @@ -582,7 +584,13 @@ struct RemoteFolderListing: View { var body: some View { FolderListing( path: browser.path, parentPath: browser.parentPath, - entries: browser.sortedEntries.map { FolderListingEntry(name: $0.name, isDirectory: $0.isDirectory) }, + 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 @@ -629,16 +637,20 @@ struct FolderListing: View { } private var listing: some View { - ScrollView { - VStack(spacing: 0) { - if let parentPath { - entryRow(name: "..", isDirectory: true) { go(parentPath) } - } - ForEach(entries) { entry in - entryRow(name: entry.name, isDirectory: entry.isDirectory) { enter(entry.name) } + 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) @@ -650,20 +662,42 @@ struct FolderListing: View { } } - 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)) ?? "") + .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()) diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 3fd3e252a1..ec22e68815 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9313,7 +9313,8 @@ "native.headValue": "HEAD: {arg1}", "native.resetToActualSize100": "Reset to actual size (100%)", "native.oneUsage": "1 usage", - "native.openLocalFolder": "Local Folder" + "native.openLocalFolder": "Local Folder", + "native.size": "Size" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index c09571204e..f9956837fe 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9313,7 +9313,8 @@ "native.headValue": "HEAD:{arg1}", "native.resetToActualSize100": "重置为实际大小(100%)", "native.oneUsage": "1 处使用", - "native.openLocalFolder": "本地文件夹" + "native.openLocalFolder": "本地文件夹", + "native.size": "大小" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 90c387cb0e..5428c024f3 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88369,6 +88369,17 @@ "key": "Local Folder", "arguments": [] } + }, + { + "id": "native.size", + "description": "Native UI: Size", + "platforms": [ + "macos" + ], + "macos": { + "key": "Size", + "arguments": [] + } } ] } From da793bb83d4a25a927cb7f9450035cc18034f33a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 22:16:15 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(macos):=20=E8=BF=9C=E7=A8=8B=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E7=AA=97=E5=8F=A3=E7=9A=84=E4=B8=BB=E6=9C=BA=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E8=A1=8C=E4=B8=8D=E5=86=8D=E6=92=91=E5=87=BA=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E9=95=BF=E4=BD=8D=E7=BD=AE=E5=90=8D=E6=88=AA?= =?UTF-8?q?=E6=96=AD=E6=98=BE=E7=A4=BA=20(#1487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/LitheSettingsControls.swift | 22 +++++++++++++++++-- .../Workspace/RemoteConnectionsView.swift | 3 ++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index bb4e66265f..9b585da99e 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 shrinks and ends in an ellipsis instead of keeping its + /// full width, so it cannot push the row past its container. + private let truncates: Bool init(_ title: LocalizedStringKey, kind: Kind = .neutral) { self.text = Text(title) self.kind = kind + self.truncates = false } - init(verbatim title: String, kind: Kind = .neutral) { + init(verbatim title: String, kind: Kind = .neutral, truncates: Bool = false) { self.text = Text(verbatim: title) self.kind = kind + self.truncates = truncates } var body: some View { @@ -222,7 +227,8 @@ struct LitheSettingsPill: View { .padding(.horizontal, 8) .padding(.vertical, 2) .background(Capsule().fill(background)) - .fixedSize() + .truncationMode(.tail) + .modifier(PillSizing(truncates: truncates)) } private var foreground: Color { @@ -244,6 +250,18 @@ struct LitheSettingsPill: View { } } +private struct PillSizing: ViewModifier { + let truncates: Bool + + func body(content: Content) -> some View { + if truncates { + content + } 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/RemoteConnectionsView.swift b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift index 0a1f207880..65e1c9a695 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -333,7 +333,8 @@ struct RemoteConnectionsView: View { 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) + LitheSettingsPill(verbatim: Self.place(of: location), kind: .neutral, truncates: true) + .help(Text(verbatim: Self.place(of: location))) } } badges: { if feature.isLocating { From 8b974a839b6b97020d1c366ca1dce7bb0137a5c2 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 22:17:44 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(macos):=20=E8=BF=9C=E7=A8=8B=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E7=AA=97=E5=8F=A3=E7=9A=84=E5=9C=B0=E5=9D=80=E8=A1=8C?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=B8=A4=E8=A1=8C=EF=BC=8C=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=8D=A2=E8=A1=8C=E5=AE=8C=E6=95=B4=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20(#1487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/LitheSettingsControls.swift | 28 +++++++++-------- .../Workspace/RemoteConnectionsView.swift | 30 +++++++++++-------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index 9b585da99e..faf93607b0 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -201,20 +201,20 @@ struct LitheSettingsPill: View { private let text: Text private let kind: Kind - /// A long value shrinks and ends in an ellipsis instead of keeping its - /// full width, so it cannot push the row past its container. - private let truncates: Bool + /// 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.truncates = false + self.wraps = false } - init(verbatim title: String, kind: Kind = .neutral, truncates: Bool = false) { + init(verbatim title: String, kind: Kind = .neutral, wraps: Bool = false) { self.text = Text(verbatim: title) self.kind = kind - self.truncates = truncates + self.wraps = wraps } var body: some View { @@ -222,13 +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)) - .truncationMode(.tail) - .modifier(PillSizing(truncates: truncates)) + .background( + RoundedRectangle(cornerRadius: wraps ? 10 : 100, style: .continuous).fill(background) + ) + .modifier(PillSizing(wraps: wraps)) } private var foreground: Color { @@ -251,11 +253,11 @@ struct LitheSettingsPill: View { } private struct PillSizing: ViewModifier { - let truncates: Bool + let wraps: Bool func body(content: Content) -> some View { - if truncates { - content + if wraps { + content.fixedSize(horizontal: false, vertical: true) } else { content.fixedSize() } diff --git a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift index 65e1c9a695..fdb27ce952 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -331,19 +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, truncates: true) - .help(Text(verbatim: Self.place(of: location))) - } - } 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()) } } } From f017ea6bd62d8bb755c2e38d4fc08e03e9657b61 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 22:20:27 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(matrix):=20=E5=90=8C=E6=AD=A5=E6=89=93?= =?UTF-8?q?=E5=BC=80=E9=A1=B9=E7=9B=AE=E9=9D=A2=E6=9D=BF=E4=B8=8E=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E5=BC=80=E5=8F=91=E7=AA=97=E5=8F=A3=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94=E7=9A=84=E8=83=BD=E5=8A=9B=E8=AE=B0=E5=BD=95=20(#1487?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/remote-development-workspace.json | 6 ++++-- .../features/workspace-open-switch.json | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/shared/platform-feature-matrix/features/remote-development-workspace.json b/shared/platform-feature-matrix/features/remote-development-workspace.json index 71a9f5c0be..64d144cec4 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 9ea2200018..3b3fefd194 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 取消,左下角“远程开发”进入连接管理。" } From 0b74cebb8e51f823a9869a5a2b05a2b95c69ea94 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 25 Oct 2026 22:32:23 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(macos):=20=E6=89=93=E5=BC=80=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=9C=AC=E5=9C=B0=E7=9B=AE=E5=BD=95=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E3=80=81=E8=B7=AF=E5=BE=84=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E4=B8=8E=E7=A9=BA=E7=9B=AE=E5=BD=95=E6=8F=90=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20review=20=E5=8F=8D=E9=A6=88=20(#1487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Resources/en.lproj/Localizable.strings | 4 +++ .../zh-Hans.lproj/Localizable.strings | 4 +++ .../RemoteConnectionsFeatureModel.swift | 9 ++++-- .../Core/Ports/RemoteConnectionProbing.swift | 7 ++++ .../AppModel/AppModel+LocalFolders.swift | 13 ++++++-- macos/Sources/Lithe/Views/App/RootView.swift | 8 ++--- .../Views/Workspace/OpenProjectPanel.swift | 32 +++++++++++++++---- .../Workspace/RemoteConnectionsView.swift | 13 +++++++- .../LitheTests/RemoteConnectionsTests.swift | 18 +++++++++++ shared/i18n/locales/en-US.json | 4 ++- shared/i18n/locales/zh-CN.json | 4 ++- shared/i18n/messages.json | 22 +++++++++++++ 12 files changed, 119 insertions(+), 19 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 942f3c3189..18e2c00fd4 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3877,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."; @@ -8278,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 7ffdc7d897..41473b7769 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3877,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." = "这会永久删除“%@”中的未提交文件和未跟踪文件,但会保留对应分支。"; @@ -8278,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 1049838590..21d2b36906 100644 --- a/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift @@ -342,8 +342,13 @@ final class RemoteConnectionsFeatureModel: ObservableObject { /// 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, - let connection = connections.first(where: { $0.id == browser.connectionID }) else { return } + 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() diff --git a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift index 2a593b15d6..6c8fd7c492 100644 --- a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift +++ b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift @@ -170,6 +170,13 @@ struct RemoteDirectoryEntry: Decodable, Equatable, Identifiable, Sendable { 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 index d8e543c947..ef8ace4a86 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift @@ -1,7 +1,7 @@ import Foundation /// One row of the Open panel's folder list. -struct FolderListingEntry: Identifiable, Equatable { +struct FolderListingEntry: Identifiable, Equatable, Sendable { let name: String let isDirectory: Bool /// Bytes of a file; `nil` for folders and when unknown. @@ -16,9 +16,16 @@ extension AppModel { var localHomeFolderPath: String { services.fileStorage.homeDirectory().path } /// The visible entries of a local folder, folders first; `nil` when `path` - /// is not a readable folder. - func localFolderEntries(at path: String) -> [FolderListingEntry]? { + /// 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) diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 9b277e8aa4..a29e9b3257 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -326,8 +326,8 @@ private struct ActiveSessionChrome: View { get: { session.workbenchFeature.isOpenTargetChooserPresented }, set: { session.workbenchFeature.isOpenTargetChooserPresented = $0 } )) { - if let feature = session.makeRemoteConnectionsFeature() { - OpenProjectPanelSheet(feature: feature) + if session.canManageRemoteConnections { + OpenProjectPanelSheet(feature: session.makeRemoteConnectionsFeature()!) .environmentObject(session) } } @@ -335,8 +335,8 @@ private struct ActiveSessionChrome: View { 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/Workspace/OpenProjectPanel.swift b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift index 6e369a1eb4..76061ca9d9 100644 --- a/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift +++ b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift @@ -27,6 +27,8 @@ struct OpenProjectPanel: View { @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" @@ -63,7 +65,10 @@ struct OpenProjectPanel: View { feature.cancelTest() if id != nil { feature.browseRemoteFolder() } } - .onDisappear { feature.close() } + .onDisappear { + localTask?.cancel() + feature.close() + } } // MARK: Sidebar @@ -100,7 +105,7 @@ struct OpenProjectPanel: View { if feature.selection == nil { FolderListing( path: localPath, parentPath: localPath == "/" ? nil : (localPath as NSString).deletingLastPathComponent, - entries: localEntries, isLoading: false, error: localError, + entries: localEntries, isLoading: localIsLoading, error: localError, go: { listLocal(($0 as NSString).expandingTildeInPath) }, enter: { listLocal((localPath as NSString).appendingPathComponent($0)) } ) @@ -132,6 +137,8 @@ struct OpenProjectPanel: View { 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() } } @@ -152,16 +159,27 @@ struct OpenProjectPanel: View { } /// Lists `path` on this Mac; a path that cannot be read keeps the previous - /// listing and shows why. + /// listing and shows why. A newer request replaces one still loading. private func listLocal(_ path: String) { - let normalized = URL(fileURLWithPath: path).standardizedFileURL.path - guard let entries = model.localFolderEntries(at: normalized) else { + guard path.hasPrefix("/") else { localError = L10n.string("Enter an absolute path that starts with /.") return } - localPath = normalized - localEntries = entries + 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() { diff --git a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift index fdb27ce952..777a6bef31 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -536,7 +536,9 @@ enum ParentWindowFit { private static let margin: CGFloat = 24 static func size(preferred: CGSize, minimum: CGSize) -> CGSize { - guard let parent = NSApp.keyWindow?.contentLayoutRect.size else { return preferred } + // 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)) @@ -631,6 +633,13 @@ struct FolderListing: View { .buttonStyle(LitheActionButtonStyle()) } listing + .overlay { + if entries.isEmpty, !isLoading, error == nil { + Text(LocalizedStringKey("This folder is empty")) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + } + } if let error { HStack(alignment: .top, spacing: 6) { Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(LitheTheme.warning) @@ -699,6 +708,8 @@ struct FolderListing: View { .lineLimit(1) .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) diff --git a/macos/Tests/LitheTests/RemoteConnectionsTests.swift b/macos/Tests/LitheTests/RemoteConnectionsTests.swift index 9e264f2424..abdaef450a 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 ec22e68815..123e1518ec 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9314,7 +9314,9 @@ "native.resetToActualSize100": "Reset to actual size (100%)", "native.oneUsage": "1 usage", "native.openLocalFolder": "Local Folder", - "native.size": "Size" + "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 f9956837fe..653c83f087 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9314,7 +9314,9 @@ "native.resetToActualSize100": "重置为实际大小(100%)", "native.oneUsage": "1 处使用", "native.openLocalFolder": "本地文件夹", - "native.size": "大小" + "native.size": "大小", + "native.thisFolderCannotBeRead": "无法读取此文件夹。", + "native.thisFolderIsEmpty": "此文件夹为空" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 5428c024f3..7f3af48db1 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88380,6 +88380,28 @@ "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": [] + } } ] }