From 5de5de731a2a40861984b549fa7d68b6a8f218ab Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Wed, 26 Aug 2026 16:54:02 +0800 Subject: [PATCH] feat(harmonyos): browse remote workspace folders --- .../entry/src/main/ets/i18n/EnUsMessages.ets | 10 + .../entry/src/main/ets/i18n/ZhCnMessages.ets | 10 + .../entry/src/main/ets/model/RemoteModels.ets | 27 ++ .../actions/AppRootPresentationActions.ets | 8 +- .../components/AppRootOverlaySurfaces.ets | 2 +- .../SidebarRemoteDirectoryPicker.ets | 302 ++++++++++++++++++ .../components/SidebarWorkspacePicker.ets | 156 --------- .../components/SidebarWorkspaceSection.ets | 25 +- .../pages/components/WideConversationHost.ets | 2 +- .../main/ets/pages/runtime/AppRootRuntime.ets | 11 +- .../runtime/AppRootRuntimeComposition.ets | 7 +- .../main/ets/pages/state/RemotePageState.ets | 9 + .../viewmodel/RemoteConnectionController.ets | 2 + .../viewmodel/RemoteWorkspaceViewModel.ets | 14 +- .../pages/viewmodel/SettingsController.ets | 3 + .../ets/services/RemoteCommandFactory.ets | 8 + .../ets/services/RemoteSessionManager.ets | 25 +- .../services/RemoteWorkspaceCoordinator.ets | 6 +- .../services/RemoteWorkspaceRepository.ets | 7 +- .../entry/src/test/LocalTestFixtures.ets | 10 + .../test/TransportAndGeneralChatUnit.test.ets | 3 + .../mobile/harmonyos/tools/fake-relay.mjs | 21 ++ .../src/remote_connect.rs | 186 +++++++++++ .../tests/remote_connect_contracts.rs | 1 + 24 files changed, 667 insertions(+), 188 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarRemoteDirectoryPicker.ets delete mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 50bbe9f254..6bede50080 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -114,6 +114,16 @@ export const EN_US_MESSAGES: [string, string][] = [ ['sidebar.deviceOnline', 'Online'], ['sidebar.addWorkspace', 'Add workspace'], ['sidebar.selectWorkspace', 'Select workspace'], + ['sidebar.selectRemoteFolder', 'Select remote folder'], + ['sidebar.currentFolder', 'Current location'], + ['sidebar.remoteHomeFolder', 'Device home folder'], + ['sidebar.parentFolder', 'Parent folder'], + ['sidebar.chooseThisFolder', 'Choose this folder'], + ['sidebar.loadingFolders', 'Loading remote folders…'], + ['sidebar.emptyFolders', 'No subfolders here'], + ['sidebar.directoryBrowserUnsupported', 'This computer does not support remote folder selection yet. Update BitFun first.'], + ['sidebar.directoryLoadFailed', 'Could not load the remote folder'], + ['sidebar.directoryTruncated', 'Only the first 500 folders are shown'], ['sidebar.workspacesOffline', 'Disconnected'], ['sidebar.connectDesktop', 'Connect a computer'], ['sidebar.addConnection', 'Add connection'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index 0637bf31ac..003602eec0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -114,6 +114,16 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['sidebar.deviceOnline', '在线'], ['sidebar.addWorkspace', '添加工作区'], ['sidebar.selectWorkspace', '选择工作区'], + ['sidebar.selectRemoteFolder', '选择远程文件夹'], + ['sidebar.currentFolder', '当前位置'], + ['sidebar.remoteHomeFolder', '设备主目录'], + ['sidebar.parentFolder', '上一级'], + ['sidebar.chooseThisFolder', '选择此文件夹'], + ['sidebar.loadingFolders', '正在读取远程文件夹…'], + ['sidebar.emptyFolders', '这里没有子文件夹'], + ['sidebar.directoryBrowserUnsupported', '这台电脑的版本暂不支持远程文件夹选择,请先更新 BitFun'], + ['sidebar.directoryLoadFailed', '远程文件夹读取失败'], + ['sidebar.directoryTruncated', '文件夹较多,仅显示前 500 项'], ['sidebar.workspacesOffline', '未连接'], ['sidebar.connectDesktop', '连接电脑'], ['sidebar.addConnection', '添加连接'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index b8214a73d4..5c7108136e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -33,6 +33,7 @@ export interface InitialSyncResult { sessions: RemoteSession[]; hasMoreSessions: boolean; authenticatedUserId: string; + capabilities?: string[]; } export interface RecentWorkspaceEntry { @@ -42,6 +43,18 @@ export interface RecentWorkspaceEntry { workspaceKind: string; } +export interface RemoteDirectoryEntry { + name: string; + path: string; +} + +export interface RemoteDirectoryListing { + path: string; + parentPath: string; + directories: RemoteDirectoryEntry[]; + truncated: boolean; +} + export interface RemoteSession { id: string; title: string; @@ -162,6 +175,7 @@ export interface WorkspaceInfoResponse extends CommandStatusResponse { git_branch?: string; workspace_kind?: string; assistant_id?: string; + capabilities?: string[]; } export interface RecentWorkspaceEntryResponse { @@ -178,6 +192,18 @@ export interface RecentWorkspaceListResponse extends CommandStatusResponse { workspaces?: RecentWorkspaceEntryResponse[]; } +export interface RemoteDirectoryEntryResponse { + name?: string; + path?: string; +} + +export interface RemoteDirectoryListResponse extends CommandStatusResponse { + path?: string; + parent?: string; + directories?: RemoteDirectoryEntryResponse[]; + truncated?: boolean; +} + export interface SetWorkspaceResponse extends CommandStatusResponse { success?: boolean; path?: string; @@ -251,6 +277,7 @@ export interface InitialSyncResponse extends CommandStatusResponse { sessions?: SessionItemResponse[]; has_more_sessions?: boolean; authenticated_user_id?: string; + capabilities?: string[]; } export interface DelegatedIdentityResponse extends CommandStatusResponse { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index 7f3c290baf..640dd97e88 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -1,4 +1,4 @@ -import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; +import { RemoteDirectoryListing, RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { DetectedUrlAction } from '../../services/ConnectScanDecisionPolicy'; import { ConversationIntent } from './ConversationIntent'; @@ -62,7 +62,7 @@ export interface RemoteHomePresentationActions { readonly createInWorkspace: (path: string, agentType: string, deviceId?: string) => void; readonly createInWorkspaceInPlace: (path: string, agentType: string, deviceId?: string) => void; readonly selectDirectoryDevice: (deviceId: string) => void; - readonly addDirectoryWorkspace: (deviceId: string) => void; + readonly browseDirectory: (deviceId: string, path: string) => Promise; readonly retryDirectoryDevice: (deviceId: string) => void; readonly openSession: (session: RemoteSession) => void; readonly openSessionInPlace: (session: RemoteSession) => void; @@ -131,7 +131,9 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, search: () => {}, loadMore: () => {}, reconnect: () => {}, disconnect: () => {}, clearPairing: () => {}, create: () => {}, createInPlace: () => {}, createAssistant: () => {}, createInWorkspace: () => {}, - createInWorkspaceInPlace: () => {}, selectDirectoryDevice: () => {}, addDirectoryWorkspace: () => {}, + createInWorkspaceInPlace: () => {}, selectDirectoryDevice: () => {}, browseDirectory: async () => ({ + path: '', parentPath: '', directories: [], truncated: false + }), retryDirectoryDevice: () => {}, openSession: () => {}, openSessionInPlace: () => {}, deleteSession: () => {} }, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index df4d679bed..a4be5554ff 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -90,7 +90,7 @@ export struct AppSidebarSurface { }, onSelectDevice: this.actions.onRemoteHome.selectDirectoryDevice, onRetryDevice: this.actions.onRemoteHome.retryDirectoryDevice, - onAddWorkspace: this.actions.onRemoteHome.addDirectoryWorkspace + onBrowseDirectory: this.actions.onRemoteHome.browseDirectory }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarRemoteDirectoryPicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarRemoteDirectoryPicker.ets new file mode 100644 index 0000000000..ca4b94028e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarRemoteDirectoryPicker.ets @@ -0,0 +1,302 @@ +import { RemoteDirectoryEntry, RemoteDirectoryListing } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { + CARD, + INK, + LINE, + MUTED, + PAGE_BG, + PRIMARY_ACTION, + PRIMARY_ACTION_TEXT, + RED, + SOFT +} from './Theme'; + +@ComponentV2 +export struct SidebarRemoteDirectoryPicker { + @Param deviceName: string = ''; + @Event onClose: () => void = () => {}; + @Event onLoadDirectories: (path: string) => Promise = + async (_path: string): Promise => ({ + path: '', parentPath: '', directories: [], truncated: false + }); + @Event onSelectDirectory: (path: string) => void = (_path: string) => {}; + @Local currentPath: string = ''; + @Local parentPath: string = ''; + @Local directories: RemoteDirectoryEntry[] = []; + @Local loading: boolean = true; + @Local errorText: string = ''; + @Local truncated: boolean = false; + private requestVersion: number = 0; + + aboutToAppear(): void { + void this.loadDirectory(''); + } + + build() { + Column() { + this.Header() + Divider().color(LINE) + this.PathBar() + if (this.loading) { + this.LoadingState() + } else if (this.errorText.length > 0) { + this.ErrorState() + } else { + this.DirectoryList() + } + if (!this.loading && this.errorText.length === 0 && this.currentPath.length > 0) { + this.ChooseButton() + } + } + .width('100%') + .height('100%') + .padding({ left: 18, right: 18, top: 10, bottom: 12 }) + .backgroundColor(PAGE_BG) + } + + @Builder + private Header() { + Row({ space: 12 }) { + Column({ space: 2 }) { + Text(RemoteI18n.t('sidebar.selectRemoteFolder')) + .width('100%') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + Text(this.deviceName) + .width('100%') + .fontSize(13) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(17) + .fontColor([MUTED]) + } + .width(40) + .height(40) + .borderRadius(20) + .backgroundColor(SOFT) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) + } + .width('100%') + .height(66) + .alignItems(VerticalAlign.Center) + } + + @Builder + private PathBar() { + Column({ space: 4 }) { + Text(RemoteI18n.t('sidebar.currentFolder')) + .width('100%') + .fontSize(11) + .fontColor(MUTED) + Text(this.currentPath.length > 0 ? this.currentPath : + (this.loading ? RemoteI18n.t('common.loading') : RemoteI18n.t('sidebar.remoteHomeFolder'))) + .width('100%') + .fontSize(13) + .fontColor(INK) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(58) + .padding({ left: 12, right: 12, top: 9, bottom: 8 }) + .margin({ top: 10, bottom: 4 }) + .alignItems(HorizontalAlign.Start) + .justifyContent(FlexAlign.Center) + .backgroundColor(SOFT) + .borderRadius(12) + } + + @Builder + private DirectoryList() { + if (this.directories.length === 0 && this.parentPath.length === 0) { + this.EmptyState() + } else { + Scroll() { + Column({ space: 4 }) { + if (this.parentPath.length > 0) { + this.ParentRow() + } + ForEach(this.directories, (directory: RemoteDirectoryEntry) => { + this.DirectoryRow(directory) + }, (directory: RemoteDirectoryEntry): string => directory.path) + if (this.directories.length === 0) { + Text(RemoteI18n.t('sidebar.emptyFolders')) + .width('100%') + .fontSize(13) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .padding({ top: 28, bottom: 18 }) + } + if (this.truncated) { + Text(RemoteI18n.t('sidebar.directoryTruncated')) + .width('100%') + .fontSize(12) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .padding({ top: 10, bottom: 12 }) + } + } + .width('100%') + .padding({ top: 8, bottom: 14 }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + } + + @Builder + private ParentRow() { + Row({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.arrow_up')) + .fontSize(18) + .fontColor([INK]) + } + .width(34) + .height(34) + .borderRadius(10) + .backgroundColor(CARD) + Text(RemoteI18n.t('sidebar.parentFolder')) + .fontSize(15) + .fontColor(INK) + .layoutWeight(1) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + } + .width('100%') + .height(58) + .padding({ left: 10, right: 10 }) + .borderRadius(14) + .onClick(() => void this.loadDirectory(this.parentPath)) + } + + @Builder + private DirectoryRow(directory: RemoteDirectoryEntry) { + Row({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.folder')) + .fontSize(20) + .fontColor([INK]) + } + .width(34) + .height(34) + .borderRadius(10) + .backgroundColor(CARD) + Text(directory.name) + .fontSize(15) + .fontColor(INK) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + } + .width('100%') + .height(58) + .padding({ left: 10, right: 10 }) + .borderRadius(14) + .onClick(() => void this.loadDirectory(directory.path)) + } + + @Builder + private ChooseButton() { + Text(RemoteI18n.t('sidebar.chooseThisFolder')) + .width('100%') + .height(48) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT) + .textAlign(TextAlign.Center) + .backgroundColor(PRIMARY_ACTION) + .borderRadius(14) + .onClick(() => this.onSelectDirectory(this.currentPath)) + } + + @Builder + private LoadingState() { + Column({ space: 12 }) { + LoadingProgress().width(22).height(22).color(MUTED) + Text(RemoteI18n.t('sidebar.loadingFolders')).fontSize(14).fontColor(MUTED) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private EmptyState() { + Column({ space: 8 }) { + SymbolGlyph($r('sys.symbol.folder')).fontSize(30).fontColor([MUTED]) + Text(RemoteI18n.t('sidebar.emptyFolders')) + .fontSize(14) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private ErrorState() { + Column({ space: 10 }) { + Text(this.errorText) + .fontSize(14) + .fontColor(RED) + .textAlign(TextAlign.Center) + Text(RemoteI18n.t('common.retry')) + .height(42) + .padding({ left: 22, right: 22 }) + .fontSize(14) + .fontColor(INK) + .textAlign(TextAlign.Center) + .backgroundColor(SOFT) + .borderRadius(21) + .onClick(() => void this.loadDirectory(this.currentPath)) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + private async loadDirectory(path: string): Promise { + const version = ++this.requestVersion; + this.loading = true; + this.errorText = ''; + try { + const listing = await this.onLoadDirectories(path); + if (version !== this.requestVersion) { + return; + } + this.currentPath = listing.path; + this.parentPath = listing.parentPath; + this.directories = listing.directories; + this.truncated = listing.truncated; + } catch (err) { + if (version !== this.requestVersion) { + return; + } + const detail = String(err).replace(/^Error:\s*/, '').trim(); + this.errorText = detail || RemoteI18n.t('sidebar.directoryLoadFailed'); + } finally { + if (version === this.requestVersion) { + this.loading = false; + } + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets deleted file mode 100644 index 88fd43b67c..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets +++ /dev/null @@ -1,156 +0,0 @@ -import { RecentWorkspaceEntry } from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; - -@ComponentV2 -export struct SidebarWorkspacePicker { - @Param deviceName: string = ''; - @Param workspaces: RecentWorkspaceEntry[] = []; - @Param selectedWorkspacePath: string = ''; - @Param loading: boolean = false; - @Event onClose: () => void = () => {}; - @Event onSelectWorkspace: (path: string) => void = (_path: string) => {}; - - build() { - Column() { - this.Header() - Divider().color(LINE) - if (this.loading && this.workspaces.length === 0) { - this.LoadingState() - } else if (this.workspaces.length === 0) { - this.EmptyState() - } else { - Scroll() { - Column({ space: 4 }) { - ForEach(this.workspaces, (workspace: RecentWorkspaceEntry) => { - this.WorkspaceRow(workspace) - }, (workspace: RecentWorkspaceEntry): string => workspace.path) - } - .width('100%') - .padding({ top: 10, bottom: 18 }) - } - .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Off) - } - } - .width('100%') - .height('100%') - .padding({ left: 18, right: 18, top: 10 }) - .backgroundColor(PAGE_BG) - } - - @Builder - private Header() { - Row({ space: 12 }) { - Column({ space: 2 }) { - Text(RemoteI18n.t('sidebar.selectWorkspace')) - .width('100%') - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.deviceName) - .width('100%') - .fontSize(13) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(17) - .fontColor([MUTED]) - } - .width(40) - .height(40) - .borderRadius(20) - .backgroundColor(SOFT) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => this.onClose()) - } - .width('100%') - .height(66) - .alignItems(VerticalAlign.Center) - } - - @Builder - private WorkspaceRow(workspace: RecentWorkspaceEntry) { - Row({ space: 12 }) { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.folder')) - .fontSize(20) - .fontColor([INK]) - } - .width(34) - .height(34) - .borderRadius(10) - .backgroundColor(CARD) - Column({ space: 3 }) { - Text(workspace.name || this.basename(workspace.path)) - .width('100%') - .fontSize(16) - .fontWeight(workspace.path === this.selectedWorkspacePath ? FontWeight.Medium : FontWeight.Regular) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(workspace.path) - .width('100%') - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph(workspace.path === this.selectedWorkspacePath ? - $r('sys.symbol.checkmark') : $r('sys.symbol.chevron_right')) - .fontSize(workspace.path === this.selectedWorkspacePath ? 17 : 15) - .fontColor([workspace.path === this.selectedWorkspacePath ? INK : MUTED]) - } - .width(40) - .height(40) - } - .width('100%') - .height(68) - .padding({ left: 10, right: 8 }) - .borderRadius(14) - .backgroundColor(workspace.path === this.selectedWorkspacePath ? SOFT : PAGE_BG) - .onClick(() => this.onSelectWorkspace(workspace.path)) - } - - @Builder - private LoadingState() { - Column({ space: 12 }) { - LoadingProgress().width(22).height(22).color(MUTED) - Text(RemoteI18n.t('common.loading')).fontSize(14).fontColor(MUTED) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - } - - @Builder - private EmptyState() { - Column({ space: 8 }) { - SymbolGlyph($r('sys.symbol.folder')).fontSize(30).fontColor([MUTED]) - Text(RemoteI18n.t('sidebar.emptyWorkspaces')) - .fontSize(14) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - } - - private basename(path: string): string { - const normalized = path.replace(/\/+$/, ''); - const index = normalized.lastIndexOf('/'); - return index >= 0 ? normalized.substring(index + 1) : normalized; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets index b3fefa0f76..9ff126adb3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets @@ -1,4 +1,4 @@ -import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { RecentWorkspaceEntry, RemoteDirectoryListing, RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DeviceDirectoryEntry, @@ -15,7 +15,7 @@ import { import { AdaptiveSheetOptions } from './AdaptiveSheetOptions'; import { GREEN, INK, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; import { SidebarDeviceGroup } from './SidebarDeviceGroup'; -import { SidebarWorkspacePicker } from './SidebarWorkspacePicker'; +import { SidebarRemoteDirectoryPicker } from './SidebarRemoteDirectoryPicker'; /** * Flat device selector followed by the selected device's workspace tree. @@ -49,7 +49,10 @@ export struct SidebarWorkspaceSection { (_deviceId: string, _path: string) => {}; @Event onSelectDevice: (deviceId: string) => void = (_deviceId: string) => {}; @Event onRetryDevice: (deviceId: string) => void = (_deviceId: string) => {}; - @Event onAddWorkspace: (deviceId: string) => void = (_deviceId: string) => {}; + @Event onBrowseDirectory: (deviceId: string, path: string) => Promise = + async (_deviceId: string, _path: string): Promise => ({ + path: '', parentPath: '', directories: [], truncated: false + }); @Local visibleDeviceCount: number = SidebarDirectoryPreviewPolicy.PREVIEW_COUNT; @Local showWorkspacePicker: boolean = false; @Local workspacePickerDeviceId: string = ''; @@ -177,7 +180,7 @@ export struct SidebarWorkspaceSection { .accessibilityText(RemoteI18n.t('sidebar.addWorkspace')) .onClick(() => { if (this.canUseDevice(entry)) { - this.openWorkspacePicker(entry); + this.openDirectoryPicker(entry); } }) } @@ -254,16 +257,15 @@ export struct SidebarWorkspaceSection { @Builder private WorkspacePicker() { - SidebarWorkspacePicker({ + SidebarRemoteDirectoryPicker({ deviceName: this.workspacePickerDevice().deviceName, - workspaces: this.workspacesForDevice(this.workspacePickerDevice()), - selectedWorkspacePath: this.isActiveDevice(this.workspacePickerDevice().deviceId) ? - this.workspacePath : '', - loading: this.workspacePickerDevice().status === 'loading', onClose: () => { this.showWorkspacePicker = false; }, - onSelectWorkspace: (path: string) => { + onLoadDirectories: async (path: string): Promise => { + return await this.onBrowseDirectory(this.workspacePickerDeviceId, path); + }, + onSelectDirectory: (path: string) => { const deviceId = this.workspacePickerDeviceId; this.showWorkspacePicker = false; this.onOpenWorkspace(deviceId, path); @@ -271,10 +273,9 @@ export struct SidebarWorkspaceSection { }) } - private openWorkspacePicker(entry: DeviceDirectoryEntry): void { + private openDirectoryPicker(entry: DeviceDirectoryEntry): void { this.workspacePickerDeviceId = entry.deviceId; this.showWorkspacePicker = true; - this.onAddWorkspace(entry.deviceId); } private workspacePickerDevice(): DeviceDirectoryEntry { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index c3d33e629c..1e532c05ec 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -217,7 +217,7 @@ export struct WideConversationHost { }, onSelectDevice: this.actions.onRemoteHome.selectDirectoryDevice, onRetryDevice: this.actions.onRemoteHome.retryDirectoryDevice, - onAddWorkspace: this.actions.onRemoteHome.addDirectoryWorkspace + onBrowseDirectory: this.actions.onRemoteHome.browseDirectory }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index f95e0521a5..23e0759d05 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -1,4 +1,4 @@ -import { RemoteSession, SessionSummary } from '../../model/RemoteModels'; +import { RemoteDirectoryListing, RemoteSession, SessionSummary } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; @@ -253,15 +253,14 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } } - async openWorkspacePickerOnDevice(deviceId: string): Promise { + async listDirectoriesOnDevice(deviceId: string, path: string): Promise { await this.deviceDirectoryViewModel.selectDevice(deviceId); const entry = this.deviceDirectoryState.find(deviceId); if (entry && !entry.online && !this.isConnectedDirectoryTarget(deviceId)) { - return; - } - if (this.isConnectedDirectoryTarget(deviceId)) { - await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); } + await this.conversationController.ensureRemoteControlTarget(deviceId); + return await this.remoteWorkspaceViewModel.listDirectories(path); } private isConnectedDirectoryTarget(deviceId: string): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 67148ce3a0..6000c16c61 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -3,6 +3,7 @@ import { RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, + RemoteDirectoryListing, RemoteSession, SelectedImageAttachment, SessionSummary, @@ -134,7 +135,7 @@ export abstract class AppRootRuntimeComposition { abstract openAddConnectionFromSettings(): void; abstract openAppSidebar(): void; abstract openRemoteControlSettings(): void; - abstract openWorkspacePickerOnDevice(deviceId: string): Promise; + abstract listDirectoriesOnDevice(deviceId: string, path: string): Promise; abstract pickImages(): Promise; abstract publishRemoteSessions(sessions: RemoteSession[], hasMore: boolean): void; abstract reconcileCreatedRemoteSession(session: SessionSummary): Promise; @@ -872,8 +873,8 @@ export abstract class AppRootRuntimeComposition { selectDirectoryDevice: (deviceId: string): void => { void this.selectDirectoryDevice(deviceId); }, - addDirectoryWorkspace: (deviceId: string): void => { - void this.openWorkspacePickerOnDevice(deviceId); + browseDirectory: async (deviceId: string, path: string): Promise => { + return await this.listDirectoriesOnDevice(deviceId, path); }, retryDirectoryDevice: (deviceId: string): void => { void this.deviceDirectoryViewModel.retryDevice(deviceId); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index e72fe68688..864c75ca16 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -26,6 +26,7 @@ export class RemotePageState { @Trace accountUsername: string = ''; @Trace accountUserId: string = ''; @Trace authenticatedUserId: string = ''; + @Trace hostCapabilities: string[] = []; @Trace controlTargetType: string = 'none'; @Trace controlTargetDeviceId: string = ''; @Trace controlTargetDeviceName: string = ''; @@ -132,6 +133,14 @@ export class RemotePageState { this.authenticatedUserId = authenticatedUserId; } + setHostCapabilities(capabilities: string[]): void { + this.hostCapabilities = capabilities.slice(); + } + + supportsHostCapability(capability: string): boolean { + return this.hostCapabilities.indexOf(capability) >= 0; + } + setStatusText(statusText: string): void { this.conversation.setStatusText(statusText); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index 506ca73ff7..300f98b81e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -168,6 +168,7 @@ export class RemoteConnectionController { this.lockUntil = 0; this.applyWorkspace(initialSync.workspace); this.pageState.setAuthenticatedUserId(initialSync.authenticatedUserId); + this.pageState.setHostCapabilities(initialSync.capabilities || []); this.pageState.setControlTarget('room', this.pageState.desktopId, this.pageState.desktopName); this.sessions.setSessions(initialSync.sessions, initialSync.hasMoreSessions); this.setState(RemoteConnectionState.Connected); @@ -236,6 +237,7 @@ export class RemoteConnectionController { this.files.clear(); this.pageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); this.pageState.setAuthenticatedUserId(''); + this.pageState.setHostCapabilities([]); this.pageState.clearControlTarget(); this.pageState.clearWorkspaceActions(); this.setState(RemoteConnectionState.Disconnected); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets index f6d3d475ac..b4b06ff77b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets @@ -1,4 +1,4 @@ -import { RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../../model/RemoteModels'; +import { RecentWorkspaceEntry, RemoteDirectoryListing, RemoteSession, WorkspaceInfo } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; @@ -15,6 +15,8 @@ export interface RemoteWorkspaceViewModelHooks { readonly onConnectionFailure: (error: Object) => void; } +const WORKSPACE_DIRECTORY_BROWSER_CAPABILITY: string = 'workspace_directory_browser_v1'; + /** Owns the workspace/assistant picker workflows and their presentation state. */ export class RemoteWorkspaceViewModel { private readonly pageState: RemotePageState; @@ -59,6 +61,16 @@ export class RemoteWorkspaceViewModel { return await this.select(path, true); } + async listDirectories(path: string): Promise { + if (!this.hooks.isRemoteAvailable()) { + throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); + } + if (!this.pageState.supportsHostCapability(WORKSPACE_DIRECTORY_BROWSER_CAPABILITY)) { + throw new Error(RemoteI18n.t('sidebar.directoryBrowserUnsupported')); + } + return await this.coordinator.directories(path); + } + async loadRecentWorkspacesInBackground(): Promise { try { const recent = await this.coordinator.recentWorkspaces(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index a4d83ac76d..18894eeb54 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -543,6 +543,7 @@ export class SettingsController { ); cloud.hooks.onControlTargetChanged(); cloud.remoteState.setAuthenticatedUserId(initialSync.authenticatedUserId); + cloud.remoteState.setHostCapabilities(initialSync.capabilities || []); cloud.remoteState.setConnectionState('connected'); this.resyncAccountDevicePresence(); cloud.remoteState.setStatusText(RemoteI18n.t('connection.connected')); @@ -776,6 +777,7 @@ export class SettingsController { cloud.remoteState.setConnectionState('reconnecting'); cloud.remoteState.setLoadingHome(true); cloud.remoteState.clearControlTarget(); + cloud.remoteState.setHostCapabilities([]); cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); cloud.remoteState.setBusy(true); cloud.remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); @@ -798,6 +800,7 @@ export class SettingsController { cloud.remoteState.setAuthenticatedUserId(''); } cloud.remoteState.clearControlTarget(); + cloud.remoteState.setHostCapabilities([]); cloud.remoteState.setConnectionState('disconnected'); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets index 466bbdf424..4a2022ef9c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets @@ -9,6 +9,14 @@ export class RemoteCommandFactory { return { cmd: 'list_recent_workspaces' }; } + static listDirectories(path: string): RemoteCommand { + const command: RemoteCommand = { cmd: 'list_directories' }; + if (path.trim().length > 0) { + command.path = path.trim(); + } + return command; + } + static setWorkspace(path: string): RemoteCommand { return { cmd: 'set_workspace', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 7fce39eca0..f8836c260c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,4 +1,4 @@ -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteDirectoryEntry, RemoteDirectoryEntryResponse, RemoteDirectoryListing, RemoteDirectoryListResponse, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { PairIdentity, PeerDeviceProvisionOutcome, RelayHttpClient } from './RelayHttpClient'; import { CloudAccountClient, CloudAccountRequestError, CloudAccountSession } from './CloudAccountClient'; @@ -76,7 +76,8 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile workspace: this.workspace, sessions: RemoteResponseMapper.sessions(initialSync.sessions || []), hasMoreSessions: initialSync.has_more_sessions || false, - authenticatedUserId: initialSync.authenticated_user_id || '' + authenticatedUserId: initialSync.authenticated_user_id || '', + capabilities: initialSync.capabilities || [] }; } @@ -150,7 +151,8 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile workspace: this.workspace, sessions: sessions.sessions, hasMoreSessions: sessions.hasMore, - authenticatedUserId: session.userId + authenticatedUserId: session.userId, + capabilities: workspaceResponse.capabilities || [] }; } @@ -164,6 +166,23 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return RemoteResponseMapper.recentWorkspaces(response.workspaces || []); } + async listDirectories(path: string): Promise { + const response = await this.send(RemoteCommandFactory.listDirectories(path)); + const directories = (response.directories || []) + .filter((entry: RemoteDirectoryEntryResponse): boolean => + (entry.name || '').length > 0 && (entry.path || '').length > 0) + .map((entry: RemoteDirectoryEntryResponse): RemoteDirectoryEntry => ({ + name: entry.name || '', + path: entry.path || '' + })); + return { + path: response.path || '', + parentPath: response.parent || '', + directories, + truncated: response.truncated || false + }; + } + async setWorkspace(path: string): Promise { const response = await this.send(RemoteCommandFactory.setWorkspace(path)); if (response.success === false) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets index 2202cd0382..6f1bc6bf28 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets @@ -1,4 +1,4 @@ -import { AssistantEntry, RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../model/RemoteModels'; +import { AssistantEntry, RecentWorkspaceEntry, RemoteDirectoryListing, RemoteSession, WorkspaceInfo } from '../model/RemoteModels'; import { CONCURRENT_WORKSPACE_LISTINGS, mapInConcurrentBatches, @@ -19,6 +19,10 @@ export class RemoteWorkspaceCoordinator { return await this.repository.listRecentWorkspaces(); } + async directories(path: string): Promise { + return await this.repository.listDirectories(path); + } + async assistants(): Promise { return await this.repository.listAssistants(); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets index cfd6c9d43d..44cd6022ee 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets @@ -1,8 +1,9 @@ -import { AssistantEntry, RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../model/RemoteModels'; +import { AssistantEntry, RecentWorkspaceEntry, RemoteDirectoryListing, RemoteSession, WorkspaceInfo } from '../model/RemoteModels'; import { RemoteSessionManager } from './RemoteSessionManager'; export interface RemoteWorkspaceDataSource { listRecentWorkspaces(): Promise; + listDirectories(path: string): Promise; setWorkspace(path: string): Promise; listAssistants(): Promise; setAssistant(path: string): Promise; @@ -23,6 +24,10 @@ export class RemoteWorkspaceRepository implements RemoteWorkspaceDataSource { return await this.sessionManager.listRecentWorkspaces(); } + async listDirectories(path: string): Promise { + return await this.sessionManager.listDirectories(path); + } + async setWorkspace(path: string): Promise { await this.sessionManager.setWorkspace(path); return await this.sessionManager.getWorkspaceInfo(); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index d413001c37..54510e4f74 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -117,6 +117,7 @@ import { AssistantEntry, CreateSessionOptions, RemoteImageContext, + RemoteDirectoryListing, RemoteQuestionAnswerPayload, RemoteModelCatalog, ReadFileChunkResult, @@ -170,6 +171,15 @@ export class FakeRemoteWorkspaceDataSource implements RemoteWorkspaceDataSource return this.recent; } + async listDirectories(path: string): Promise { + return { + path: path || '/Users/test', + parentPath: path.length > 0 ? '/Users' : '/', + directories: [], + truncated: false + }; + } + async setWorkspace(_path: string): Promise { return { name: 'workspace', path: '/workspace', hasWorkspace: true, diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index e1f994ed72..fb69439e69 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -1542,6 +1542,9 @@ export default function transportAndGeneralChatUnitTest() { it('builds workspace and assistant commands', 0, () => { expectCommandJson(RemoteCommandFactory.getWorkspaceInfo(), '{"cmd":"get_workspace_info"}'); expectCommandJson(RemoteCommandFactory.listRecentWorkspaces(), '{"cmd":"list_recent_workspaces"}'); + expectCommandJson(RemoteCommandFactory.listDirectories(''), '{"cmd":"list_directories"}'); + expectCommandJson(RemoteCommandFactory.listDirectories('/Users/user'), + '{"cmd":"list_directories","path":"/Users/user"}'); expectCommandJson(RemoteCommandFactory.setWorkspace('/repo'), '{"cmd":"set_workspace","path":"/repo"}'); expectCommandJson(RemoteCommandFactory.listAssistants(), '{"cmd":"list_assistants"}'); expectCommandJson(RemoteCommandFactory.setAssistant('/assistant'), '{"cmd":"set_assistant","path":"/assistant"}'); diff --git a/src/apps/mobile/harmonyos/tools/fake-relay.mjs b/src/apps/mobile/harmonyos/tools/fake-relay.mjs index 5e23fc07f0..13ccca58db 100644 --- a/src/apps/mobile/harmonyos/tools/fake-relay.mjs +++ b/src/apps/mobile/harmonyos/tools/fake-relay.mjs @@ -568,6 +568,7 @@ function responseFor(command) { git_branch: currentWorkspace.git_branch, workspace_kind: currentWorkspace.workspace_kind, assistant_id: currentWorkspace.assistant_id, + capabilities: ['workspace_directory_browser_v1'], }; case 'list_recent_workspaces': return { @@ -593,6 +594,25 @@ function responseFor(command) { }, ], }; + case 'list_directories': { + const path = String(command.path || '/workspace/demo'); + const normalized = path.replace(/\\/g, '/').replace(/\/$/, '') || '/'; + const parentIndex = normalized.lastIndexOf('/'); + const parent = normalized === '/' ? undefined : + (parentIndex <= 0 ? '/' : normalized.slice(0, parentIndex)); + const names = normalized === '/workspace/demo' ? ['BitFun', 'Documents', 'Projects'] : + normalized === '/workspace/demo/Projects' ? ['mobile-app', 'relay-service'] : []; + return { + resp: 'directory_list', + path: normalized, + parent, + directories: names.map((name) => ({ + name, + path: normalized === '/' ? `/${name}` : `${normalized}/${name}`, + })), + truncated: false, + }; + } case 'set_workspace': { const path = String(command.path || '').trim(); @@ -968,6 +988,7 @@ const server = http.createServer(async (req, res) => { git_branch: currentWorkspace.git_branch, workspace_kind: currentWorkspace.workspace_kind, assistant_id: currentWorkspace.assistant_id, + capabilities: ['workspace_directory_browser_v1'], authenticated_user_id: command.user_id, sessions: currentSessionItems().slice(0, 8), has_more_sessions: currentSessionItems().length > 8, diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 4b49761982..0375617c3a 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -525,6 +525,95 @@ where pub const REMOTE_FILE_MAX_READ_BYTES: u64 = 30 * 1024 * 1024; pub const REMOTE_FILE_MAX_CHUNK_BYTES: u64 = 3 * 1024 * 1024; +pub const REMOTE_DIRECTORY_MAX_ENTRIES: usize = 500; +pub const REMOTE_CAPABILITY_WORKSPACE_DIRECTORY_BROWSER_V1: &str = "workspace_directory_browser_v1"; + +fn remote_host_capabilities() -> Vec { + vec![REMOTE_CAPABILITY_WORKSPACE_DIRECTORY_BROWSER_V1.to_string()] +} + +fn remote_directory_wire_path(path: &Path) -> String { + let path = path.to_string_lossy(); + #[cfg(windows)] + let path = path + .strip_prefix(r"\\?\UNC\") + .map(|path| format!("//{path}")) + .or_else(|| path.strip_prefix(r"\\?\").map(ToOwned::to_owned)) + .unwrap_or_else(|| path.into_owned()); + #[cfg(not(windows))] + let path = path.into_owned(); + + path.replace('\\', "/") +} + +fn remote_directory_list_response(path: Option<&str>) -> RemoteResponse { + let requested_path = path.filter(|path| !path.trim().is_empty()); + let directory = match requested_path { + Some(path) => PathBuf::from(path), + None => match dirs::home_dir().or_else(|| std::env::current_dir().ok()) { + Some(path) => path, + None => { + return RemoteResponse::Error { + message: "Unable to resolve the remote home directory".into(), + }; + } + }, + }; + let directory = match directory.canonicalize() { + Ok(path) if path.is_dir() => path, + Ok(_) => { + return RemoteResponse::Error { + message: "The selected remote path is not a directory".into(), + }; + } + Err(error) => { + return RemoteResponse::Error { + message: format!("Unable to open remote directory: {error}"), + }; + } + }; + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) => { + return RemoteResponse::Error { + message: format!("Unable to read remote directory: {error}"), + }; + } + }; + + let mut directories: Vec = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let metadata = entry.metadata().ok()?; + if !metadata.is_dir() { + return None; + } + Some(RemoteDirectoryEntry { + name: entry.file_name().to_string_lossy().into_owned(), + path: remote_directory_wire_path(&entry.path()), + }) + }) + .take(REMOTE_DIRECTORY_MAX_ENTRIES + 1) + .collect(); + directories.sort_by(|left, right| { + left.name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.name.cmp(&right.name)) + }); + let truncated = directories.len() > REMOTE_DIRECTORY_MAX_ENTRIES; + directories.truncate(REMOTE_DIRECTORY_MAX_ENTRIES); + + RemoteResponse::DirectoryList { + path: remote_directory_wire_path(&directory), + parent: directory + .parent() + .map(remote_directory_wire_path) + .filter(|parent| parent != &remote_directory_wire_path(&directory)), + directories, + truncated, + } +} pub fn resolve_remote_file_chunk_range( file_len: usize, @@ -899,6 +988,7 @@ pub fn remote_workspace_info_response(workspace: Option) - assistant_id: workspace.assistant_id, remote_connection_id: workspace.remote_connection_id, remote_ssh_host: workspace.remote_ssh_host, + capabilities: remote_host_capabilities(), }, None => RemoteResponse::WorkspaceInfo { has_workspace: false, @@ -909,6 +999,7 @@ pub fn remote_workspace_info_response(workspace: Option) - assistant_id: None, remote_connection_id: None, remote_ssh_host: None, + capabilities: remote_host_capabilities(), }, } } @@ -1072,6 +1163,7 @@ pub fn remote_initial_sync_response( sessions, has_more_sessions, authenticated_user_id, + capabilities: remote_host_capabilities(), } } @@ -1086,6 +1178,7 @@ where RemoteCommand::ListRecentWorkspaces => { remote_recent_workspaces_response(host.recent_workspaces().await) } + RemoteCommand::ListDirectories { path } => remote_directory_list_response(path.as_deref()), RemoteCommand::SetWorkspace { path, remote_connection_id, @@ -2116,6 +2209,12 @@ pub struct RecentWorkspaceEntry { pub remote_ssh_host: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteDirectoryEntry { + pub name: String, + pub path: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AssistantEntry { pub path: String, @@ -2166,6 +2265,10 @@ pub enum RemotePermissionMode { pub enum RemoteCommand { GetWorkspaceInfo, ListRecentWorkspaces, + ListDirectories { + #[serde(default)] + path: Option, + }, SetWorkspace { path: String, #[serde(default)] @@ -2357,10 +2460,19 @@ pub enum RemoteResponse { remote_connection_id: Option, #[serde(skip_serializing_if = "Option::is_none")] remote_ssh_host: Option, + #[serde(default)] + capabilities: Vec, }, RecentWorkspaces { workspaces: Vec, }, + DirectoryList { + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + parent: Option, + directories: Vec, + truncated: bool, + }, WorkspaceUpdated { success: bool, path: Option, @@ -2434,6 +2546,8 @@ pub enum RemoteResponse { has_more_sessions: bool, #[serde(skip_serializing_if = "Option::is_none")] authenticated_user_id: Option, + #[serde(default)] + capabilities: Vec, }, SessionPoll { version: u64, @@ -2583,6 +2697,7 @@ where RemoteCommand::GetWorkspaceInfo | RemoteCommand::ListRecentWorkspaces + | RemoteCommand::ListDirectories { .. } | RemoteCommand::SetWorkspace { .. } | RemoteCommand::ListAssistants | RemoteCommand::SetAssistant { .. } => host.handle_workspace_command(command).await, @@ -3696,6 +3811,7 @@ mod tests { assistant_id: None, remote_connection_id: None, remote_ssh_host: None, + capabilities: remote_host_capabilities(), } ); @@ -3720,6 +3836,76 @@ mod tests { ); } + #[tokio::test] + async fn remote_workspace_handler_lists_only_remote_directories() { + let host = FakeWorkspaceHost; + let root = tempfile::tempdir().expect("create temp directory"); + std::fs::create_dir(root.path().join("Beta")).expect("create Beta directory"); + std::fs::create_dir(root.path().join("alpha")).expect("create alpha directory"); + std::fs::write(root.path().join("notes.txt"), "not a directory") + .expect("create regular file"); + + let response = handle_remote_workspace_command( + &host, + &RemoteCommand::ListDirectories { + path: Some(root.path().to_string_lossy().into_owned()), + }, + ) + .await; + + let RemoteResponse::DirectoryList { + path, + parent, + directories, + truncated, + } = response + else { + panic!("expected a directory list response"); + }; + let canonical_root = root + .path() + .canonicalize() + .expect("canonicalize temp directory"); + assert_eq!(path, remote_directory_wire_path(&canonical_root)); + assert_eq!( + parent, + canonical_root.parent().map(remote_directory_wire_path) + ); + assert_eq!( + directories + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + vec!["alpha", "Beta"] + ); + assert!(!truncated); + } + + #[test] + fn directory_browser_wire_shapes_are_backward_compatible() { + let legacy_workspace_info: RemoteResponse = serde_json::from_value(serde_json::json!({ + "resp": "workspace_info", + "has_workspace": false, + "path": null, + "project_name": null, + "git_branch": null + })) + .expect("deserialize response without capabilities"); + assert!(matches!( + legacy_workspace_info, + RemoteResponse::WorkspaceInfo { capabilities, .. } if capabilities.is_empty() + )); + + let command = RemoteCommand::ListDirectories { path: None }; + assert_eq!( + serde_json::to_value(command).expect("serialize directory command"), + serde_json::json!({ "cmd": "list_directories", "path": null }) + ); + assert!(remote_host_capabilities() + .iter() + .any(|capability| capability == REMOTE_CAPABILITY_WORKSPACE_DIRECTORY_BROWSER_V1)); + } + #[derive(Default)] struct FakeSessionHost { created_requests: Mutex>, diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index d2407b3aa3..4668b53012 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -877,6 +877,7 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { assistant_id: None, remote_connection_id: None, remote_ssh_host: None, + capabilities: Vec::new(), } }