From 436ee95d6acddd58c14319bc2618e2a9c8cae1f3 Mon Sep 17 00:00:00 2001 From: huxc573 Date: Sat, 17 Oct 2026 18:01:31 +0800 Subject: [PATCH] =?UTF-8?q?ui:=20the=20second=20walk-through=20=E2=80=94?= =?UTF-8?q?=20focus,=20favourites,=20and=20the=20phone's=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ends - One dark palette: the desktop stops using its own near-black and takes the phone's values. - QuietButton is `.btn` again — border, fill, hover. 新建文件夹, the ⋮ menu and the phone's display-mode choices were plain text sitting where a button belongs. - The switch is 46×26 and the segmented control matches it; Material's 52×32 sat next to a 24px control like another design language. - A notice follows the theme and leaves after four seconds instead of waiting to be clicked. - Clicking a pane lights it; that is the pane 显示方式 acts on. - The pane header's icons are tighter, and 展开 (a new button beside the star) lists the paths that pane has visited, on both ends. - A successful handshake now puts **every** shared folder in the remote pane. Opening only the first made a machine sharing two folders look like a machine sharing one. Favourites - Grouped by 本端 / 远端 and protocol, with named groups, rename and re-group. - A menu on both ends — right click on the desktop, long press on the phone. - Order: drag on the desktop after pressing 排序, up/down on the phone. The plus became a sort icon and adding a path moved into the menu. - The rail reads `settingsRevision`, so a favourite written anywhere appears at once. Desktop - The context menu opens at the pointer, not in the middle of the window. Phone - Back returns to the previous section; it used to jump to 传输 from anywhere. - Breadcrumbs keep the longest path. Going up leaves the deeper layers on screen, the current one lit, every layer tappable, and a long press copies that layer's path — which is what Mix does and what the design asked for. - The connect card names the peer, its folder count, and offers 断开. - The display pill is the mark and the mode; 列数 only exists where a mode has columns. Root and Shizuku are still not implemented, and the note says so: both need a backend that speaks through su or the Shizuku API, and an unverified one would be a control that looks like it works and cannot read a file. --- CHANGELOG.md | 22 + .../io/github/cubsync/android/AndroidApp.kt | 109 ++++- .../io/github/cubsync/android/AndroidState.kt | 62 ++- .../io/github/cubsync/android/Components.kt | 17 +- .../github/cubsync/android/ConnectScreen.kt | 24 +- .../github/cubsync/android/TransferScreen.kt | 47 +- .../io/github/cubsync/desktop/AppState.kt | 90 +++- .../kotlin/io/github/cubsync/desktop/Theme.kt | 29 +- .../github/cubsync/desktop/ui/Components.kt | 65 ++- .../io/github/cubsync/desktop/ui/Shell.kt | 401 +++++++++++++++--- .../cubsync/desktop/ui/TransferScreen.kt | 84 +++- docs/releases/v0.3.14.md | 112 +++++ gradle.properties | 2 +- .../io/github/cubsync/service/AppSettings.kt | 72 +++- .../github/cubsync/service/BrowserSession.kt | 49 +++ .../io/github/cubsync/service/IconPaths.kt | 3 + .../io/github/cubsync/service/Sidebar.kt | 17 +- 17 files changed, 1064 insertions(+), 141 deletions(-) create mode 100644 docs/releases/v0.3.14.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdc1e1..ecdb70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ with the pre-1.0 rules described in [docs/VERSIONING.md](docs/VERSIONING.md). ## [Unreleased] +## [0.3.14] - 2026-10-17 + +### Fixed + +- Both ends: one dark palette (the phone's), real buttons with a border and a hover, a 46×26 + switch, themed notices that disappear after four seconds, and a lit focused pane. +- Favourites: grouped by end and protocol, custom groups, a right-click / long-press menu + (open, edit, move, remove, new group, add path, settings), drag order on the desktop, up/down on + the phone, and a rail that updates as soon as a favourite is written. +- Desktop: the context menu opens at the pointer; 展开 lists the paths a pane has visited. +- After connecting, the remote pane lists every shared folder instead of the first one. +- Phone: back returns to the previous section; breadcrumbs keep the longest path, light the current + layer and copy a layer on a long press; the connect card names the peer and offers 断开; the + display pill drops the extra words and hides 列数 where a mode has none. + +### Known gaps + +- Root / Shizuku remain a recorded setting: both need a backend that speaks through `su` or the + Shizuku API, and that has not been verified on a device. +- WebDAV / FTP / SFTP / SMB are not implemented. +- The desktop folder picker is still the system dialog. + ## [0.3.13] - 2026-10-17 ### Fixed diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt index bb8abd3..fc5d239 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt @@ -43,6 +43,9 @@ import io.github.cubsync.service.IconPaths import io.github.cubsync.service.Section import io.github.cubsync.service.Words import kotlinx.coroutines.delay +import io.github.cubsync.service.Favorite +import io.github.cubsync.service.PathTools +import io.github.cubsync.service.Side /** * The phone: a top bar, one screen, and a bar of five. @@ -95,6 +98,8 @@ fun CubAndroidApp(state: AndroidAppState) { state.sharedSheet -> SharedRootsSheet(state) } Overlays(state) + state.favouriteMenu?.let { FavouriteSheet(state, it) } + state.historySide?.let { HistorySheet(state, it) } if (state.drawerOpen) Drawer(state) // The drawer also opens with a left swipe, which is what the design document lists @@ -283,6 +288,102 @@ private fun NoticeBar(state: AndroidAppState) { } } +/** + * 收藏夹's row menu, opened by a long press. + * + * The desktop opens the same set on a right click: rename, re-group, move, remove. A phone + * has no right button, so the long press is the one it has — the drawer row keeps its tap + * for opening the folder. + */ +@Composable +fun FavouriteSheet(state: AndroidAppState, favourite: Favorite) { + val colors = CubTheme.colors + var label by remember(favourite.key) { + mutableStateOf(favourite.label.ifEmpty { PathTools.leafName(favourite.path) }) + } + var group by remember(favourite.key) { mutableStateOf(favourite.groupName) } + var newGroup by remember { mutableStateOf("") } + + BottomSheet(onClose = { state.favouriteMenu = null }) { + SheetHeader(favourite.label.ifEmpty { favourite.path }) { state.favouriteMenu = null } + Text(favourite.path, style = CubType.mono, color = colors.textFaint, maxLines = 2) + Spacer(Modifier.height(8.dp)) + + Field( + value = label, + onValueChange = { label = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "名称", + onSubmit = { state.renameFavourite(favourite, label, group) }, + ) + Spacer(Modifier.height(8.dp)) + Text("分组", style = CubType.rowDetail, color = colors.textFaint) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + for (name in state.service.settings.favouriteHeadings().take(3)) { + QuietButton(name) { group = if (group == name) "" else name } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Field( + value = newGroup, + onValueChange = { newGroup = it }, + modifier = Modifier.weight(1f), + placeholder = "新建分组", + onSubmit = { + state.addFavouriteGroup(newGroup) + newGroup = "" + }, + ) + Spacer(Modifier.width(6.dp)) + QuietButton("新建") { + state.addFavouriteGroup(newGroup) + newGroup = "" + } + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton("保存") { + state.renameFavourite(favourite, label, group) + state.favouriteMenu = null + } + QuietButton("上移") { state.moveFavourite(favourite, -1) } + QuietButton("下移") { state.moveFavourite(favourite, +1) } + } + Spacer(Modifier.height(6.dp)) + ListRow("取消收藏", favourite.path, leadingIcon = IconPaths.CLOSE) { + state.removeFavourite(favourite) + state.favouriteMenu = null + } + Spacer(Modifier.height(6.dp)) + } +} + +/** 展开: the paths this pane has been to. */ +@Composable +fun HistorySheet(state: AndroidAppState, side: Side) { + val colors = CubTheme.colors + BottomSheet(onClose = { state.historySide = null }) { + SheetHeader("展开 · ${if (side == Side.LEFT) Words.LOCAL_PANE else Words.REMOTE_PANE}") { + state.historySide = null + } + val recent = state.browsing.recent(side) + if (recent.isEmpty()) { + Text("这个栏还没有打开过别的目录。", style = CubType.rowDetail, color = colors.textFaint) + } + for (path in recent.take(10)) { + ListRow( + title = PathTools.leafName(path).ifEmpty { path }, + detail = path, + leadingIcon = IconPaths.CLOCK, + ) { + state.historySide = null + state.openPane(side, path, state.browsing.current(side).backendId) + } + } + Spacer(Modifier.height(6.dp)) + } +} + /** The drawer: favourites, accounts, roots. Opened by `☰`, which is on every top-level screen. */ @Composable private fun Drawer(state: AndroidAppState) { @@ -347,11 +448,9 @@ private fun Drawer(state: AndroidAppState) { detail = item.path, leadingIcon = IconPaths.FOLDER, modifier = Modifier.padding(horizontal = 8.dp), - trailing = { - IconButton(IconPaths.CLOSE, size = 30, mark = 13) { - state.removeFavourite(item) - } - }, + // A long press is the phone's right click: rename, regroup, + // move, remove — the same menu the desktop's right click opens. + onLongClick = { state.favouriteMenu = item }, onClick = { state.openFavourite(item) }, ) } diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt index e388fe6..f17e1ee 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt @@ -209,7 +209,18 @@ class AndroidAppState( // ---------------------------------------------------------------- navigation + /** Where the last few sections came from, so ← goes back the way it came. */ + private val sectionHistory = ArrayDeque
() + private var goingBack = false + fun go(target: Section) { + if (goingBack) { + goingBack = false + } else if (target != tab) { + sectionHistory.remove(target) + sectionHistory.addLast(tab) + if (sectionHistory.size > 12) sectionHistory.removeFirst() + } tab = target route = Route.Tabs drawerOpen = false @@ -236,7 +247,7 @@ class AndroidAppState( val right = service.browsing.current(Side.RIGHT) if (right.backendId == EndpointId.LOCAL) { peers.firstOrNull()?.let { peer -> - service.browsing.open(Side.RIGHT, peer.backendId, peer.roots.firstOrNull().orEmpty()) + service.browsing.open(Side.RIGHT, peer.backendId, "") } } } @@ -267,7 +278,12 @@ class AndroidAppState( paneMenu != null -> paneMenu = null actionSheet != null -> actionSheet = null route !is Route.Tabs -> route = Route.Tabs - tab != Section.TRANSFER -> go(Section.TRANSFER) + sectionHistory.isNotEmpty() -> { + // Popped rather than pushed: going back must not record the place it is + // leaving, or ← would bounce between two screens forever. + goingBack = true + go(sectionHistory.removeLast()) + } else -> { val side = liveSettings.activeSide service.browsing.up(side) @@ -281,7 +297,7 @@ class AndroidAppState( get() { if (drawerOpen || moreSheet || connectSheet || sharedSheet || screenMenu || paneMenu != null || actionSheet != null || route !is Route.Tabs || - tab != Section.TRANSFER + sectionHistory.isNotEmpty() ) { return true } @@ -505,6 +521,34 @@ class AndroidAppState( touch() } + /** The drawer row whose menu is open. */ + var favouriteMenu by mutableStateOf(null) + + /** The pane whose 展开 list is open. */ + var historySide by mutableStateOf(null) + + fun moveFavourite(favourite: Favorite, by: Int) { + val all = service.settings.favourites.toMutableList() + val from = all.indexOfFirst { it.key == favourite.key } + if (from < 0) return + val to = (from + by).coerceIn(0, all.lastIndex) + if (to == from) return + val moved = all.removeAt(from) + all.add(to, moved) + service.settings.reorderFavourites(all) + revision++ + } + + fun renameFavourite(favourite: Favorite, label: String, group: String) { + service.settings.updateFavourite(favourite, label = label, group = group) + revision++ + } + + fun addFavouriteGroup(name: String) { + val created = service.settings.addFavouriteGroup(name) + if (created.isNotEmpty()) revision++ + } + fun removeFavourite(favourite: Favorite) { service.settings.removeFavourite(favourite) revision++ @@ -728,13 +772,15 @@ class AndroidAppState( // A handshake that leaves both panes as they were is a handshake the person // cannot see. The right pane becomes the peer and opens on the first folder // it offers — the same thing the desktop does when it connects out. - val first = session.roots.firstOrNull() - service.browsing.open(Side.RIGHT, session.backendId, first.orEmpty()) + // An empty path lists every folder the other side exposes. Opening the + // first one only hid the rest: a machine sharing two folders looked like a + // machine sharing one. + service.browsing.open(Side.RIGHT, session.backendId, "") liveSettings.activeSide = Side.RIGHT notify( - if (first == null) "已连接 ${session.nodeName},但对面没有共享文件夹" - else "已连接 ${session.nodeName} · ${first}", - if (first == null) NoticeKind.WARNING else NoticeKind.SUCCESS, + if (session.roots.isEmpty()) "已连接 ${session.nodeName},但对面没有共享文件夹" + else "已连接 ${session.nodeName} · ${session.roots.size} 个共享文件夹", + if (session.roots.isEmpty()) NoticeKind.WARNING else NoticeKind.SUCCESS, ) } catch (failure: Throwable) { peerError = failure.message ?: "连接失败" diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/Components.kt b/app/android/src/main/kotlin/io/github/cubsync/android/Components.kt index d6a2e07..80af302 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/Components.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/Components.kt @@ -3,7 +3,9 @@ package io.github.cubsync.android import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -813,6 +815,7 @@ fun SheetHeader(title: String, onClose: () -> Unit) { /** One line of a list: a name, a detail and optional trailing controls. */ @Composable +@OptIn(ExperimentalFoundationApi::class) fun ListRow( title: String, detail: String = "", @@ -821,6 +824,7 @@ fun ListRow( tint: Color? = null, leadingIcon: String? = null, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, trailing: @Composable (RowScope.() -> Unit)? = null, ) { val colors = CubTheme.colors @@ -830,7 +834,18 @@ fun ListRow( .heightIn(min = 56.dp) .clip(RoundedCornerShape(10.dp)) .background(if (selected) colors.accentContainer else Color.Transparent) - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .then( + when { + // A long press is the phone's right click: the drawer's rows use it for + // their menu, without losing the tap that opens them. + onClick != null && onLongClick != null -> Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + onClick != null -> Modifier.clickable(onClick = onClick) + else -> Modifier + }, + ) .padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt b/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt index 1b119e5..c47011e 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt @@ -165,6 +165,21 @@ fun ConnectScreen(state: AndroidAppState) { SectionLabel("连接另一台设备") Panel(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) { + // What is connected, by name, with the way out beside it. The button used to + // still say 连接 after a successful handshake, and nothing said who was on + // the other end. + for (peer in state.peers) { + CardRow( + title = peer.nodeName.ifEmpty { peer.backendId }, + detail = "${peer.host}:${peer.port} · ${peer.roots.size} 个共享文件夹", + icon = IconPaths.LINK, + onClick = { + state.go(Section.TRANSFER) + state.openPane(Side.RIGHT, "", peer.backendId) + }, + trailing = { QuietButton("断开") { state.disconnectPeer(peer.backendId) } }, + ) + } Row(verticalAlignment = Alignment.CenterVertically) { Field( value = state.peerHost, @@ -183,7 +198,14 @@ fun ConnectScreen(state: AndroidAppState) { ) } Spacer(Modifier.height(8.dp)) - PrimaryButton(if (state.connecting) "连接中…" else "连接", enabled = !state.connecting) { + PrimaryButton( + when { + state.connecting -> "连接中…" + state.peers.size == 1 -> "再连一台" + else -> "连接" + }, + enabled = !state.connecting, + ) { state.connectPeerAddress() } state.peerError?.let { diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/TransferScreen.kt b/app/android/src/main/kotlin/io/github/cubsync/android/TransferScreen.kt index 39ce9cb..24d689a 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/TransferScreen.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/TransferScreen.kt @@ -208,8 +208,9 @@ private fun SubRow(state: AndroidAppState, left: PaneState, right: PaneState, bu ) { CubIcon(viewIcon(prefs.displayMode), size = 16, tint = colors.text) Spacer(Modifier.width(7.dp)) - // The prototype's pill reads 显示方式:详细 — the words, not only the mode. - Text("显示方式 · ${prefs.displayMode.label}", style = CubType.label, color = colors.text, maxLines = 1) + // The prototype's pill is the mark and the mode, nothing else. The words + // 显示方式 were a second label beside a label. + Text(prefs.displayMode.label, style = CubType.label, color = colors.text, maxLines = 1) Spacer(Modifier.width(5.dp)) CubIcon( if (settings.displayBarOpen) IconPaths.CHEVRON_DOWN else IconPaths.CHEVRON_RIGHT, @@ -287,11 +288,11 @@ private fun DisplayBar(state: AndroidAppState) { Text("100% =出厂", style = CubType.meta, color = colors.textFaint) } - Row(verticalAlignment = Alignment.CenterVertically) { - GroupLabel("列数", Modifier.width(58.dp)) - if (!prefs.displayMode.hasColumns) { - Text("${prefs.displayMode.label}没有列数参数", style = CubType.meta, color = colors.textFaint) - } else { + // 列数 exists only where a mode has columns. A row that says "详细没有列数参数" + // is a row about nothing, and the design simply does not draw it. + if (prefs.displayMode.hasColumns) { + Row(verticalAlignment = Alignment.CenterVertically) { + GroupLabel("列数", Modifier.width(58.dp)) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { for (count in Columns.SHORTCUTS) { QuietButton(count.toString()) { @@ -482,7 +483,7 @@ private fun PaneHeader(state: AndroidAppState, side: Side, pane: PaneState, acti // the header had room, so what looked like "only the parent" was really // "the parent and an ellipsis" — and a layer in the ellipsis cannot be // tapped, which is half the navigation the design promises. - val crumbs = Browsing.crumbs(pane.path) + val crumbs = state.browsing.crumbs(side) Box( Modifier .weight(1f) @@ -518,14 +519,27 @@ private fun PaneHeader(state: AndroidAppState, side: Side, pane: PaneState, acti Text( crumb.label, style = CubType.meta.copy(fontSize = 11.5.sp), - color = if (index == crumbs.lastIndex) colors.text else colors.textDim, + // The layer you are on is lit; the layers below it, + // when going back up, stay on screen and stay + // reachable in the dim colour. + color = when { + crumb.path == pane.path -> colors.text + pane.path.startsWith(crumb.path + "/") -> colors.textFaint + else -> colors.textDim + }, maxLines = 1, softWrap = false, modifier = Modifier .clip(RoundedCornerShape(4.dp)) - .clickableCustom { - state.openPane(side, crumb.path, pane.backendId) - } + .combinedClickable( + onClick = { + state.openPane(side, crumb.path, pane.backendId) + }, + onLongClick = { + copy(crumb.path) + state.notify("已复制 " + crumb.path, NoticeKind.INFO) + }, + ) .padding(horizontal = 1.dp), ) } @@ -535,13 +549,16 @@ private fun PaneHeader(state: AndroidAppState, side: Side, pane: PaneState, acti } } + // 展开, then the star, then ⋮ — packed tighter, and the new one lists the + // paths this pane has been to. + IconButton(IconPaths.CLOCK, size = 32, mark = 16) { state.historySide = side } IconButton( if (settings.isFavourite(pane.backendId, pane.path)) IconPaths.STAR else IconPaths.STAR, - size = 34, - mark = 17, + size = 32, + mark = 16, tint = if (settings.isFavourite(pane.backendId, pane.path)) colors.accent else colors.textDim, ) { state.toggleFavourite(side) } - IconButton(IconPaths.MORE, size = 34, mark = 17, stroke = 3.4f) { + IconButton(IconPaths.MORE, size = 32, mark = 16, stroke = 3.4f) { settings.activeSide = side state.paneMenu = side } diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt index f24839b..6bf33b9 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt @@ -36,6 +36,7 @@ import io.github.cubsync.service.SyncService import io.github.cubsync.service.Words import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -138,7 +139,16 @@ class AppState( /** The 显示方式 drop-down on the transfer heading. */ var displayMenu by mutableStateOf(false) + + /** 展开: the paths this pane has been to, listed beside the star. */ + var historyMenu by mutableStateOf(null) var actionSheet by mutableStateOf(null) + + /** Where the context menu was asked for, in window coordinates. */ + var actionSheetAt by mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) + + /** The transfer screen's top-left corner, so a pointer can be turned into an offset. */ + var screenOrigin by mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) var editingAddress by mutableStateOf(null) var renaming by mutableStateOf(null) var detailOf by mutableStateOf?>(null) @@ -246,8 +256,22 @@ class AppState( // ------------------------------------------------------------------ messages + /** + * Shows a notice for four seconds. + * + * It used to stay until it was clicked, which meant a window could hold a sentence from + * ten minutes ago — the person who wrote it had already read it. A newer notice cancels + * the older timer, so the last thing said is the thing that stays. + */ + private var noticeJob: Job? = null + fun notify(text: String, kind: NoticeKind = NoticeKind.INFO) { notice = Notice(text, kind) + noticeJob?.cancel() + noticeJob = scope.launch { + delay(4_000) + if (notice?.text == text) notice = null + } } fun dismissNotice() { @@ -406,6 +430,26 @@ class AppState( /** 收藏夹's `+`: a folder added by address, not only the one a pane happens to show. */ var favouriteDraft by mutableStateOf(null) + /** Which favourite's context menu is open, and where it opened. */ + var favouriteMenu by mutableStateOf(null) + var favouriteMenuAt by mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) + + /** The 收藏夹 dialogs: add a path, a new group, editing one row, or the panel settings. */ + sealed interface FavDialog { + data object AddPath : FavDialog + data object NewGroup : FavDialog + data class Edit(val favourite: Favorite) : FavDialog + data object Settings : FavDialog + } + + var favouriteDialog by mutableStateOf(null) + + /** 收藏夹's own ⋮: add a path, a group, an account, or open its settings. */ + var favouriteHeaderMenu by mutableStateOf(false) + + /** 排序模式: rows become draggable and the star is out of the way. */ + var favouriteSorting by mutableStateOf(false) + fun addFavourite(path: String) { val clean = io.github.cubsync.service.PathTools.normalizeRoot(path.trim()) if (clean.isEmpty()) return @@ -430,6 +474,42 @@ class AppState( notify("已取消收藏 ${favourite.path}", NoticeKind.INFO) } + fun renameFavourite(favourite: Favorite, label: String, group: String) { + service.settings.updateFavourite(favourite, label = label, group = group) + settingsRevision++ + notify("已保存「${label.ifEmpty { favourite.path }}」", NoticeKind.SUCCESS) + } + + fun addFavouriteGroup(name: String) { + val created = service.settings.addFavouriteGroup(name) + if (created.isEmpty()) return + settingsRevision++ + notify("已新建分组「$created」", NoticeKind.SUCCESS) + } + + /** Moves one row up or down inside the drawer — the keyboard/no-drag way to order them. */ + fun moveFavourite(favourite: Favorite, by: Int) { + val all = service.settings.favourites.toMutableList() + val from = all.indexOfFirst { it.key == favourite.key } + if (from < 0) return + val to = (from + by).coerceIn(0, all.lastIndex) + if (to == from) return + val moved = all.removeAt(from) + all.add(to, moved) + service.settings.reorderFavourites(all) + settingsRevision++ + } + + /** Drops a row onto another row's place, which is what a drag ends with. */ + fun dropFavourite(from: Int, to: Int) { + val all = service.settings.favourites.toMutableList() + if (from !in all.indices || to !in all.indices || from == to) return + val moved = all.removeAt(from) + all.add(to, moved) + service.settings.reorderFavourites(all) + settingsRevision++ + } + /** * Opens a favourite in whichever pane is focused. * @@ -728,10 +808,12 @@ class AppState( // The far end just said which folders it offers; the right pane opens on one // of them, because a pane that stays empty after a successful handshake // reads as a failed handshake. - session.roots.firstOrNull()?.let { root -> - service.browsing.open(Side.RIGHT, session.backendId, root) - } - notify("已连接 ${session.nodeName}", NoticeKind.SUCCESS) + // Every shared folder, as a list — the pane's empty path means "the roots". + service.browsing.open(Side.RIGHT, session.backendId, "") + notify( + "已连接 ${session.nodeName} · ${session.roots.size} 个共享文件夹", + NoticeKind.SUCCESS, + ) } catch (failure: Throwable) { peerError = failure.message ?: "连接失败" notify("连接失败:$peerError", NoticeKind.ERROR) diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/Theme.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/Theme.kt index 9e41ad6..e19296f 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/Theme.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/Theme.kt @@ -58,16 +58,19 @@ data class CubColors( internal val DarkCubColors = CubColors( isDark = true, - background = Color(0xFF0F1116), - surface = Color(0xFF181B21), - surfaceRaised = Color(0xFF1E222A), - surfaceSunken = Color(0xFF242932), + // The phone's dark palette, value for value. The desktop had its own near-black set + // (#0F1116 against #141318), so the two ends looked like two products side by side — + // and the phone's is the one the design tokens name. + background = Color(0xFF141318), + surface = Color(0xFF141318), + surfaceRaised = Color(0xFF1F1F26), + surfaceSunken = Color(0xFF26252C), outline = Color(0xFF2A2F39), - outlineFaint = Color(0xFF22262E), - outlineStrong = Color(0xFF2A2F39), - text = Color(0xFFE8EBF1), - textDim = Color(0xFF98A1B0), - textFaint = Color(0xFF6D7686), + outlineFaint = Color(0xFF232229), + outlineStrong = Color(0xFF3A3942), + text = Color(0xFFE6E1E9), + textDim = Color(0xFFC9C5D0), + textFaint = Color(0xFF8E8A97), accent = Color(0xFF1E88E5), // `color-mix(in srgb, var(--win-accent) 14%, transparent)` — translucent on purpose, // so it composites over whatever it is put on exactly as the prototype's does. @@ -78,10 +81,10 @@ internal val DarkCubColors = CubColors( success = Color(0xFF2E9E5B), conflict = Color(0xFFF0731F), info = Color(0xFF3B82F6), - rail = Color(0xFF1E222A), - hover = Color(0xFF232833), - selected = Color(0xFF1D2C3E), - selectedLine = Color(0xFF2B5F8F), + rail = Color(0xFF1F1F26), + hover = Color(0xFF26252C), + selected = Color(0x2E4C6EF5), + selectedLine = Color(0xFF4C6EF5), ) internal val LightCubColors = CubColors( diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Components.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Components.kt index 712a573..48727e7 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Components.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Components.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -110,6 +111,7 @@ fun QuietButton( modifier: Modifier = Modifier, enabled: Boolean = true, danger: Boolean = false, + ghost: Boolean = false, onClick: () -> Unit, ) { val colors = CubTheme.colors @@ -118,19 +120,42 @@ fun QuietButton( danger -> colors.danger else -> colors.text } + // `.btn` in the prototype: a border, a panel fill and a hover — the screen is full of + // words that do something, and a word that looks like the words around it is not a + // button. `.btn.ghost` is the exception, for the ones that sit inside a menu. + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() Box( modifier - .heightIn(min = 26.dp) + .heightIn(min = 28.dp) .clip(RoundedCornerShape(8.dp)) - .background(if (danger && enabled) colors.danger.copy(alpha = 0.12f) else Color.Transparent) - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 8.dp, vertical = 4.dp), + .background( + when { + ghost && hovered && enabled -> colors.hover + ghost -> Color.Transparent + danger && enabled -> colors.danger.copy(alpha = 0.10f) + hovered && enabled -> colors.hover + else -> colors.surface + }, + ) + .border( + 1.dp, + when { + ghost -> Color.Transparent + danger && enabled -> colors.danger.copy(alpha = 0.45f) + else -> colors.outline + }, + RoundedCornerShape(8.dp), + ) + .clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick) + .padding(horizontal = 11.dp, vertical = 5.dp), contentAlignment = Alignment.Center, ) { Text(text, style = CubType.label, color = tint, maxLines = 1) } } + /** A filled button, for the one action a panel exists to perform. */ @Composable fun PrimaryButton( @@ -225,7 +250,7 @@ fun Segmented( .clip(RoundedCornerShape(99.dp)) .background(if (active) colors.accent else Color.Transparent) .clickable { onSelect(option) } - .padding(horizontal = 10.dp, vertical = 4.dp), + .padding(horizontal = 11.dp, vertical = 6.dp), ) { Text( label(option), @@ -262,18 +287,24 @@ fun SettingRow( @Composable fun Toggle(checked: Boolean, enabled: Boolean = true, onChange: (Boolean) -> Unit) { val colors = CubTheme.colors - Switch( - checked = checked, - onCheckedChange = onChange, - enabled = enabled, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = colors.accent, - uncheckedThumbColor = colors.textFaint, - uncheckedTrackColor = colors.surfaceSunken, - uncheckedBorderColor = colors.outline, - ), - ) + // The prototype's `.sw` is 46x26 with a 15px knob. Material's is 52x32 with a 24px one, + // which sat next to a 24px segmented control like a different design language. + Box( + Modifier + .size(width = 46.dp, height = 26.dp) + .clip(RoundedCornerShape(99.dp)) + .background(if (checked) colors.accent else colors.surfaceSunken) + .border(1.5.dp, if (checked) colors.accent else colors.textFaint, RoundedCornerShape(99.dp)) + .clickable(enabled = enabled) { onChange(!checked) }, + ) { + Box( + Modifier + .padding(start = if (checked) 26.dp else 4.dp, top = 4.5.dp) + .size(15.dp) + .clip(CircleShape) + .background(if (checked) Color.White else colors.textFaint), + ) + } } /** A row of label and value, for the key/value blocks. */ diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt index 45e5fce..bf460c2 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -42,6 +43,8 @@ import io.github.cubsync.desktop.AppState import io.github.cubsync.desktop.CubTheme import io.github.cubsync.desktop.CubType import io.github.cubsync.desktop.Notice +import io.github.cubsync.service.Favorite +import io.github.cubsync.service.PathTools import io.github.cubsync.desktop.NoticeKind import io.github.cubsync.service.BuildInfo import io.github.cubsync.service.EndpointId @@ -53,6 +56,10 @@ import io.github.cubsync.service.ServerState import io.github.cubsync.service.Side import io.github.cubsync.service.Words import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.layout.positionInWindow /** * The window, built the way the prototype builds it. @@ -99,33 +106,7 @@ fun CubApp(state: AppState, chrome: WindowChrome) { // 收藏夹's `+`: the folder a pane is showing is not always the one worth keeping, so // adding a favourite takes an address rather than only the current view. - if (state.favouriteDraft != null) { - var text by remember(state.favouriteDraft) { mutableStateOf(state.favouriteDraft.orEmpty()) } - Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.3f)) - .clickable { state.favouriteDraft = null }, - contentAlignment = Alignment.Center, - ) { - FloatingCard(width = 380) { - FloatingHeader("添加收藏", onClose = { state.favouriteDraft = null }) - Block("地址") { - Field( - value = text, - onValueChange = { text = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = "收藏在聚焦的那一栏里:本端路径或远端路径", - style = CubType.meta, - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - PrimaryButton("收藏") { state.addFavourite(text) } - QuietButton("取消") { state.favouriteDraft = null } - } - } - } - } + FavouriteOverlays(state) } } @@ -264,49 +245,57 @@ private fun Rail(state: AppState, server: ServerState, folded: Boolean, onFold: if (!folded) { HorizontalDivider(Modifier.padding(top = 8.dp), color = colors.outline) Row( - Modifier.fillMaxWidth().padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 4.dp), + Modifier.fillMaxWidth().padding(start = 10.dp, end = 2.dp, top = 6.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { Text(Words.FAVOURITES, style = CubType.railLabel, color = colors.textFaint, modifier = Modifier.weight(1f)) - IconButton(IconPaths.PLUS, size = 28, mark = 13) { state.favouriteDraft = "" } + // No plus here any more: adding a folder is a menu item, and the icon that + // used to be 加 is 排序 — the same control on both ends, because a list you + // can order needs a place to say so. + IconButton( + IconPaths.SORT, + size = 26, + mark = 14, + selected = state.favouriteSorting, + tint = if (state.favouriteSorting) Color.White else null, + ) { + state.favouriteSorting = !state.favouriteSorting + } + IconButton(IconPaths.MORE, size = 24, mark = 15, stroke = 3.4f) { + state.favouriteDialog = null + state.favouriteHeaderMenu = true + } } + + // Read, not cached: a favourite added on another screen has to appear here the + // moment it is written, and this list used to wait for a section change. + state.settingsRevision val favourites = state.service.settings.favourites + val headings = state.service.settings.favouriteHeadings() + val showPath = state.service.settings.favouritesShowPath + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - // Grouped by end, not by insertion order: 本端 first, then one heading per - // peer and protocol. A list that mixes a folder on this disk with a folder - // on a phone is a list nobody can scan. - val groups = favourites.groupBy { it.group } - .toList() - .sortedWith(compareBy({ if (it.first == Words.LOCAL) 0 else 1 }, { it.first })) - for ((heading, items) in groups) { - Text( - heading, - style = CubType.tiny, - color = colors.textFaint, - modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 3.dp), - ) - for (favourite in items) { - Row( - Modifier - .fillMaxWidth() - .padding(end = 4.dp) - .clip(RoundedCornerShape(6.dp)) - .clickable { state.openFavourite(favourite) } - .padding(start = 20.dp, end = 2.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - favourite.label.ifEmpty { favourite.path }, - style = CubType.body, - color = colors.textDim, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - IconButton(IconPaths.CLOSE, size = 22, mark = 11) { - state.removeFavourite(favourite) - } - } + var flat = 0 + for (heading in headings) { + val items = favourites.filter { it.group == heading } + if (items.isEmpty() && heading !in state.service.settings.favouriteGroups) continue + Row( + Modifier.fillMaxWidth().padding(start = 10.dp, end = 4.dp, top = 7.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + heading, + style = CubType.tiny, + color = colors.textFaint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + for (item in items) { + val index = favourites.indexOfFirst { it.key == item.key }.coerceAtLeast(flat) + FavouriteRow(state, item, index, showPath, state.favouriteSorting) + flat++ } } } @@ -506,12 +495,15 @@ private fun NoticeBar(state: AppState) { Modifier .padding(bottom = 40.dp) .clip(RoundedCornerShape(9.dp)) - .background(colors.text) + // A floating bar in the window's own colours, not the inverse of them: on a + // dark desktop the old `colors.text` background came out as a white slab. + .background(colors.surfaceRaised) + .border(1.dp, colors.outlineStrong, RoundedCornerShape(9.dp)) .clickable { state.dismissNotice() } .padding(horizontal = 15.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(notice.text, style = CubType.button, color = colors.surface) + Text(notice.text, style = CubType.button, color = colors.text) Spacer(Modifier.width(8.dp)) Box(Modifier.size(7.dp).clip(CircleShape).background(tone)) } @@ -526,6 +518,283 @@ private fun colourFor(notice: Notice, colors: io.github.cubsync.desktop.CubColor NoticeKind.ERROR -> colors.danger } +/** + * One row of 收藏夹. + * + * Three ways in: a click opens it in the focused pane, a right click opens the row's menu, + * and in 排序 mode a press-and-drag moves it. The row used to be a click and a × — no menu, + * no order, and a delete that was the only thing you could do to it. + */ +@Composable +private fun FavouriteRow( + state: AppState, + favourite: Favorite, + index: Int, + showPath: Boolean, + sorting: Boolean, +) { + val colors = CubTheme.colors + var origin by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + var dragged by remember { mutableStateOf(0f) } + val rowPx = with(androidx.compose.ui.platform.LocalDensity.current) { 28.dp.toPx() } + + Row( + Modifier + .fillMaxWidth() + .onGloballyPositioned { origin = it.positionInWindow() } + .padding(end = 2.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (dragged != 0f) colors.hover else Color.Transparent) + .pointerInput(favourite.key, sorting) { + if (!sorting) return@pointerInput + detectDragGesturesAfterLongPress( + onDrag = { change, amount -> + change.consume() + dragged += amount.y + }, + onDragEnd = { + val rows = kotlin.math.round(dragged / rowPx).toInt() + val last = state.service.settings.favourites.lastIndex + if (rows != 0) state.dropFavourite(index, (index + rows).coerceIn(0, last)) + dragged = 0f + }, + onDragCancel = { dragged = 0f }, + ) + } + .pointerInput(favourite.key) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.type == androidx.compose.ui.input.pointer.PointerEventType.Press && + event.buttons.isSecondaryPressed + ) { + state.favouriteMenuAt = origin + state.favouriteMenu = favourite + } + } + } + } + .clickable { if (!sorting) state.openFavourite(favourite) } + .padding(start = 12.dp, end = 4.dp, top = 3.dp, bottom = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (sorting) { + CubIcon(IconPaths.SORT, size = 12, tint = colors.textFaint) + Spacer(Modifier.width(5.dp)) + } + Column(Modifier.weight(1f)) { + Text( + favourite.label.ifEmpty { PathTools.leafName(favourite.path).ifEmpty { favourite.path } }, + style = CubType.body, + color = colors.textDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (showPath) { + Text( + favourite.path, + style = CubType.monoSmall, + color = colors.textFaint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** The row menu, the header menu and the four dialogs 收藏夹 needs. */ +@Composable +private fun FavouriteOverlays(state: AppState) { + val colors = CubTheme.colors + val menu = state.favouriteMenu + val dialog = state.favouriteDialog + val headerMenu = state.favouriteHeaderMenu + if (menu == null && dialog == null && !headerMenu) return + + fun closeAll() { + state.favouriteMenu = null + state.favouriteDialog = null + state.favouriteHeaderMenu = false + } + + Box(Modifier.fillMaxSize()) { + Scrim { closeAll() } + + if (menu != null) { + val at = state.favouriteMenuAt + Box(Modifier.offset { androidx.compose.ui.unit.IntOffset(at.x.toInt(), at.y.toInt()) }) { + FloatingCard(width = 230) { + MenuLine("打开") { state.openFavourite(menu); closeAll() } + MenuLine("编辑…") { + state.favouriteDialog = AppState.FavDialog.Edit(menu) + state.favouriteMenu = null + } + MenuLine("上移") { state.moveFavourite(menu, -1) } + MenuLine("下移") { state.moveFavourite(menu, +1) } + MenuLine("取消收藏", danger = true) { + state.removeFavourite(menu) + closeAll() + } + } + } + } + + if (headerMenu) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + FloatingCard(width = 300) { + FloatingHeader("收藏夹", onClose = { closeAll() }) + MenuLine("添加路径…") { + state.favouriteDialog = AppState.FavDialog.AddPath + state.favouriteHeaderMenu = false + } + MenuLine("新建分组…") { + state.favouriteDialog = AppState.FavDialog.NewGroup + state.favouriteHeaderMenu = false + } + MenuLine("添加账户…(远端协议,尚未实现)", enabled = false) {} + MenuLine("收藏夹设置…") { + state.favouriteDialog = AppState.FavDialog.Settings + state.favouriteHeaderMenu = false + } + } + } + } + + when (dialog) { + is AppState.FavDialog.AddPath -> { + var text by remember { mutableStateOf("") } + FavouriteDialogCard("添加路径", onClose = { closeAll() }) { + Field( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "收藏在聚焦的那一栏里:本端路径或远端路径", + style = CubType.meta, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton("收藏") { + state.addFavourite(text) + closeAll() + } + QuietButton("取消") { closeAll() } + } + } + } + + is AppState.FavDialog.NewGroup -> { + var text by remember { mutableStateOf("") } + FavouriteDialogCard("新建分组", onClose = { closeAll() }) { + Field( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "分组名,例如「工作」「备份」", + style = CubType.meta, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton("新建") { + state.addFavouriteGroup(text) + closeAll() + } + QuietButton("取消") { closeAll() } + } + } + } + + is AppState.FavDialog.Edit -> { + val favourite = dialog.favourite + var label by remember { + mutableStateOf(favourite.label.ifEmpty { PathTools.leafName(favourite.path) }) + } + var group by remember { mutableStateOf(favourite.groupName) } + FavouriteDialogCard("编辑收藏", onClose = { closeAll() }) { + Text(favourite.path, style = CubType.monoSmall, color = colors.textFaint, maxLines = 2) + Field( + value = label, + onValueChange = { label = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "名称", + style = CubType.meta, + ) + Text("分组", style = CubType.meta, color = colors.textFaint) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + for (name in state.service.settings.favouriteHeadings().take(4)) { + QuietButton(name, ghost = group != name) { + group = if (group == name) "" else name + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton("保存") { + state.renameFavourite(favourite, label, group) + closeAll() + } + QuietButton("取消") { closeAll() } + } + } + } + + is AppState.FavDialog.Settings -> { + val settings = state.liveSettings + FavouriteDialogCard("收藏夹设置", onClose = { closeAll() }) { + SettingRow("在名称下显示路径", "一行放不下时省略") { + Toggle(settings.favouritesShowPath) { on -> + state.changeSettings { it.favouritesShowPath = on } + } + } + Text( + "排序:点亮左上角的排序图标后,按住一行拖动即可调整顺序;也可以右键用上移/下移。", + style = CubType.meta, + color = colors.textFaint, + ) + QuietButton("关闭") { closeAll() } + } + } + + null -> Unit + } + } +} + +@Composable +private fun FavouriteDialogCard(title: String, onClose: () -> Unit, content: @Composable () -> Unit) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + FloatingCard(width = 380) { + FloatingHeader(title, onClose = onClose) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { content() } + } + } +} + +/** One line of a floating menu: the whole row is the target, and it says so on hover. */ +@Composable +private fun MenuLine(text: String, enabled: Boolean = true, danger: Boolean = false, onClick: () -> Unit) { + val colors = CubTheme.colors + val interaction = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(if (hovered && enabled) colors.hover else Color.Transparent) + .clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Text( + text, + style = CubType.body, + color = when { + !enabled -> colors.textFaint + danger -> colors.danger + else -> colors.text + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + /** The label a pane's title uses. */ fun paneTitle(state: AppState, side: Side): String { val pane = state.browsing.current(side) diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt index 36d28f6..b599536 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt @@ -78,6 +78,9 @@ import io.github.cubsync.service.Words import io.github.cubsync.service.resolveDirection import io.github.cubsync.service.fileIcon import io.github.cubsync.service.viewIcon +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow /** * 传输: two panes, a display menu that acts on the pane you touched last, and the arrow @@ -105,7 +108,11 @@ fun TransferScreen(state: AppState) { val direction = resolveDirection(settings.paneDirection, 1000f, if (revision >= 0) 600f else 600f) val sideBySide = direction == PaneDirection.SIDE_BY_SIDE - Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { state.screenOrigin = it.positionInWindow() }, + ) { Column(Modifier.fillMaxSize()) { // The prototype's transfer screen has no sub-row and no standing display bar: the // three verbs are on `.chead .acts`, and 显示方式 opens a drop-down from there. Both @@ -262,15 +269,17 @@ private fun PaneHeader(state: AppState, side: Side, pane: PaneState, active: Boo onSubmit = { state.openPane(side, typed, pane.backendId); state.touchBrowsing() }, style = CubType.meta, ) - Spacer(Modifier.width(4.dp)) val starred = state.service.settings.isFavourite(pane.backendId, pane.path) + // Tighter, and one more button: 展开 lists the paths this pane has been to, so a + // folder three levels up is one click instead of three. + IconButton(IconPaths.CLOCK, size = 26, mark = 15) { state.historyMenu = side } IconButton( IconPaths.STAR, - size = 28, - mark = 16, + size = 26, + mark = 15, tint = if (starred) colors.accent else null, ) { state.toggleFavourite(side) } - IconButton(IconPaths.MORE, size = 28, mark = 18, stroke = 3.4f) { + IconButton(IconPaths.MORE, size = 26, mark = 16, stroke = 3.4f) { state.paneMenu = io.github.cubsync.desktop.PaneMenu(side) } } @@ -415,6 +424,7 @@ private fun RowLine( showMetadata: Boolean, ) { val colors = CubTheme.colors + var rowOrigin by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } Row( Modifier .fillMaxWidth() @@ -436,8 +446,12 @@ private fun RowLine( }, ) // The screen's own hint says 右键理管, and until now nothing listened for it. + .onGloballyPositioned { rowOrigin = it.positionInWindow() } .onPointerEvent(PointerEventType.Press) { event -> if (event.buttons.isSecondaryPressed) { + // Where the pointer is, not the middle of the window: a context menu + // that opens far from the thing it is about makes the person look for it. + state.actionSheetAt = rowOrigin + event.changes.first().position state.actionSheet = io.github.cubsync.desktop.ActionSheet(side, entry.relPath, entry.isDirectory) } } @@ -811,8 +825,9 @@ private fun Overlays(state: AppState) { val sheet = state.actionSheet val detail = state.detailOf val displayMenu = state.displayMenu + val history = state.historyMenu - if (paneMenu == null && sheet == null && detail == null && !displayMenu) return + if (paneMenu == null && sheet == null && detail == null && !displayMenu && history == null) return Box(Modifier.fillMaxSize()) { Scrim { @@ -821,6 +836,7 @@ private fun Overlays(state: AppState) { state.detailOf = null state.renaming = null state.displayMenu = false + state.historyMenu = null } if (displayMenu) { // Under the button that opened it: the heading's 显示方式 sits at the top-right @@ -830,13 +846,67 @@ private fun Overlays(state: AppState) { DisplayModeMenuCard(state) } } + if (history != null) { + Box(Modifier.fillMaxSize().padding(top = 6.dp, end = 14.dp), contentAlignment = Alignment.TopEnd) { + HistoryCard(state, history) + } + } Box(Modifier.fillMaxSize().padding(40.dp), contentAlignment = Alignment.Center) { when { paneMenu != null -> PaneMenuCard(state, paneMenu.side) - sheet != null -> ActionSheetCard(state, sheet.side, sheet.name, sheet.isDirectory) detail != null -> DetailCard(state, detail.first, detail.second) } } + if (sheet != null) { + val at = state.actionSheetAt - state.screenOrigin + Box( + Modifier.offset { + androidx.compose.ui.unit.IntOffset( + at.x.toInt().coerceAtLeast(0), + at.y.toInt().coerceAtLeast(0), + ) + }, + ) { + ActionSheetCard(state, sheet.side, sheet.name, sheet.isDirectory) + } + } + } +} + +/** 展开: the paths this pane has been to, newest first. */ +@Composable +private fun HistoryCard(state: AppState, side: Side) { + val colors = CubTheme.colors + val recent = state.browsing.recent(side).filter { it != state.browsing.current(side).path } + FloatingCard(width = 340) { + FloatingHeader("展开 · ${if (side == Side.LEFT) Words.LOCAL_PANE else Words.REMOTE_PANE}") { + state.historyMenu = null + } + Block("打开过的路径") { + if (recent.isEmpty()) { + Text("这个栏还没有打开过别的目录。", style = CubType.meta, color = colors.textFaint) + } + for (path in recent.take(10)) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .clickable { + state.openPane(side, path, state.browsing.current(side).backendId) + state.historyMenu = null + } + .padding(horizontal = 8.dp, vertical = 5.dp), + ) { + Text( + path, + style = CubType.monoSmall, + color = colors.textDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } } } diff --git a/docs/releases/v0.3.14.md b/docs/releases/v0.3.14.md new file mode 100644 index 0000000..62a3476 --- /dev/null +++ b/docs/releases/v0.3.14.md @@ -0,0 +1,112 @@ +
+

中文说明

+ +#### 下载 + +- **Windows**:`CubSync-0.3.14.msi`(安装)或 `cubsync-0.3.14-windows-x64.zip`(解压即用) +- **Android**:`cubsync-0.3.14.apk` + +#### 这一版修好的 + +**两端** + +- 深色配色统一按手机端:桌面原来自己一套近黑(`#0F1116`),现在两端同值(`#141318`)。 +- 按钮回到原型的样子:有边框、有底色、悬停有变化。以前很多「按钮」只是一行字(新建文件夹、 + 右上三点菜单、手机设置里的默认显示模式),现在都是按钮。 +- 开关 46×26、分段控件同高,不再一个大一个小地摆在一起。 +- 浮窗提示跟随深浅色,且 **4 秒自动消失**,不用再点。 +- 双栏焦点:点哪一栏哪一栏亮,显示方式作用在亮的这一栏。 +- 栏头右上收紧间隔,并在收藏左边加了 **展开** 按钮:列出这一栏打开过的路径,一点直达。 +- 连接之后,传输页的远端栏会列出**对面共享的全部文件夹**(以前只打开第一个)。 + +**收藏夹** + +- 按 **本端 / 远端 · 协议** 自动归类,也可以新建自己的分组;收藏项可编辑名称与分组。 +- 两端都有菜单:桌面右键、手机长按。菜单含 打开 / 编辑 / 上移 / 下移 / 取消收藏 / 新建分组 / + 添加路径 / 收藏夹设置(「添加账户」留了位置,远端协议尚未实现)。 +- 顺序可调:桌面点右上「排序」后可**拖动**,手机菜单里上移/下移; + 原来的加号换成了排序图标,添加路径移到菜单里。 +- 收藏之后左侧立刻出现,不用切页面。 + +**电脑端** + +- 右键菜单出现在**鼠标位置**,不再一律显示在窗口中间。 +- 选择文件夹…仍是系统对话框:可输入地址栏、随系统深色的自绘窗口还没做(见下)。 + +**手机端** + +- 返回键回到**上一个功能**,不是一律回传输;再没有上一屏时才退出应用。 +- 面包屑按 Mix 的方式工作:保留你打开过的最长路径,回到上层时下面几层仍在, + **当前层高亮**,点任意一层直达;**长按任一层复制该层路径**。 +- 连上之后连接卡显示对端名字、共享目录数与「断开」,按钮不再一直写着「连接」。 +- 显示方式按钮只剩图标 + 档位名(去掉了多余文字);没有列数的档位**不再显示列数行**, + 也没有「详细没有列数参数」这句废话。 + +#### 仍然没有的 + +- **Root / Shizuku 运行模式**:依旧是只记录设置。它们需要一个走 `su` / Shizuku API 的后端, + 这一版没有做——设备上的权限路径没有真机验证,宁可先不做,也不放一个看起来能用、实际读不了 + 文件的东西进去。 +- **远端协议(WebDAV / FTP / SFTP / SMB)**:收藏夹与账户的归类已按协议留位,协议本身未实现。 +- **手机端收藏夹拖动排序**:用菜单里的上移/下移,桌面端可拖。 +- **自绘的选文件夹窗口**:仍是系统对话框,因此不支持深色与地址栏。 + +
+ +
+

English

+ +#### Downloads + +- **Windows**: `CubSync-0.3.14.msi`, or `cubsync-0.3.14-windows-x64.zip` to run without installing +- **Android**: `cubsync-0.3.14.apk` + +#### Fixed + +**Both ends** + +- One dark palette, the phone's: the desktop had a near-black of its own (`#0F1116`); both are + `#141318` now. +- Buttons look like the prototype again: a border, a fill, a hover. Several "buttons" were just a + line of text — 新建文件夹, the ⋮ menu, the display-mode choices in the phone's settings. +- The switch is 46×26 and the segmented control matches it, instead of one being twice the other. +- Notices follow the theme and disappear after four seconds. +- The focused pane is the lit one, and 显示方式 acts on it. +- The pane header's three icons are tighter, and a new 展开 button beside the star lists the paths + that pane has visited. +- After connecting, the remote pane lists **every** shared folder, not just the first. + +**Favourites** + +- Grouped by 本端 / 远端 and protocol, with custom groups; a row can be renamed and re-grouped. +- A menu on both ends — right click on the desktop, long press on the phone: open, edit, move up, + move down, remove, new group, add path, favourite settings (添加账户 has a place reserved). +- Order is adjustable: drag on the desktop in 排序 mode, move up/down on the phone. The plus became + a sort icon, and adding a path moved into the menu. +- A new favourite appears in the rail immediately. + +**Desktop** + +- The context menu opens at the pointer instead of the middle of the window. +- The folder picker is still the system dialog: a self-drawn one with an address bar and the app's + dark theme is not in this build (see below). + +**Phone** + +- Back returns to the previous section instead of always 传输, and only exits at the top. +- Breadcrumbs keep the longest path you opened: going up leaves the deeper layers on screen, the + current one lit and every layer tappable; a long press copies that layer's path. +- The connect card shows the peer's name, how many folders it shares and 断开. +- The display-mode pill is the mark and the mode only, and 列数 is hidden for modes that have no + columns. + +#### Still missing + +- **Root / Shizuku**: still a recorded setting only. Both need a backend that speaks through `su` + or the Shizuku API, and that path has not been verified on a device — a control that looks like it + works and cannot read a file is worse than an honest gap. +- **WebDAV / FTP / SFTP / SMB**: the grouping has a place for them; the protocols are not built. +- **Dragging favourites on the phone**: use move up/down; the desktop supports drag. +- **A self-drawn folder picker**: the system dialog is still used, so no address bar and no dark theme. + +
diff --git a/gradle.properties b/gradle.properties index e4fa40b..29ab769 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ # The project version lives here so that build scripts, packaging and CI all read # a single value. See docs/VERSIONING.md for the versioning policy. -cubsync.version=0.3.13 +cubsync.version=0.3.14 org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 org.gradle.parallel=true diff --git a/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt b/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt index 0eb8414..6ecd32a 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt @@ -377,7 +377,75 @@ class AppSettings(private val storage: SettingsStorage = SettingsStorage.memory( favourites.any { it.backendId == backendId && it.path == path } fun removeFavourite(favorite: Favorite) { - favourites = favourites - favorite + favourites = favourites.filterNot { it.key == favorite.key } + } + + /** 收藏夹's own setting: draw each row's path under its name. */ + var favouritesShowPath: Boolean + get() = flag(KEY_FAV_SHOW_PATH, false) + set(value) { + values[KEY_FAV_SHOW_PATH] = value.toString() + persist() + } + + /** The groups somebody named, in the order they arranged them. */ + var favouriteGroups: List + get() = values[KEY_FAV_GROUPS].orEmpty().lines().map { it.trim() }.filter { it.isNotEmpty() } + set(list) { + values[KEY_FAV_GROUPS] = list.distinct().joinToString("\n") + persist() + } + + fun addFavouriteGroup(name: String): String { + val clean = name.trim() + if (clean.isEmpty()) return "" + if (clean !in favouriteGroups) favouriteGroups = favouriteGroups + clean + return clean + } + + fun renameFavouriteGroup(from: String, to: String) { + val clean = to.trim() + if (clean.isEmpty() || from == clean) return + favouriteGroups = favouriteGroups.map { if (it == from) clean else it } + favourites = favourites.map { if (it.groupName == from) it.copy(groupName = clean) else it } + } + + /** Deleting a group keeps its rows: they fall back to the heading of their own end. */ + fun deleteFavouriteGroup(name: String) { + favouriteGroups = favouriteGroups - name + favourites = favourites.map { if (it.groupName == name) it.copy(groupName = "") else it } + } + + fun updateFavourite(favorite: Favorite, label: String = favorite.label, group: String = favorite.groupName) { + favourites = favourites.map { + if (it.key == favorite.key) it.copy(label = label.trim(), groupName = group) else it + } + if (group.isNotEmpty() && group !in favouriteGroups) favouriteGroups = favouriteGroups + group + } + + /** Replaces the order — what a drag ends with. */ + fun reorderFavourites(ordered: List) { + favourites = ordered + } + + fun reorderFavouriteGroups(ordered: List) { + favouriteGroups = ordered + } + + /** + * Every heading, in the order the drawer should draw them. + * + * Named groups first, in the order they were arranged, then the automatic ones that + * have rows but no name — 本端 first among those, because it is the one that is always + * there. + */ + fun favouriteHeadings(): List { + val named = favouriteGroups + val automatic = favourites.map { it.group } + .filterNot { it in named } + .distinct() + .sortedWith(compareBy({ if (it == Words.LOCAL) 0 else 1 }, { it })) + return named + automatic } // ------------------------------------------------------------ scope layering @@ -465,6 +533,8 @@ class AppSettings(private val storage: SettingsStorage = SettingsStorage.memory( const val KEY_NODE_NAME = "server.nodeName" const val KEY_RECENT_FOLDERS = "browse.recent" const val KEY_FAVOURITES = "browse.favourites" + const val KEY_FAV_GROUPS = "browse.favouriteGroups" + const val KEY_FAV_SHOW_PATH = "browse.favouriteShowPath" const val KEY_BROWSE_SHARED = "browse.shared" } } diff --git a/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt b/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt index 44df2c9..0f70a03 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt @@ -125,10 +125,59 @@ class BrowserSession( listingGeneration[side] == generation } + /** + * The deepest path each pane has opened, which is what its breadcrumbs show. + * + * The design's address is not "the folders above this one": it is the whole route that + * got here. Going up keeps the route on screen — the layer you are on is lit and the + * ones below it stay where they were — so a step back up is one click away from being + * undone. Mix does this, and so does the prototype's `pbcrumb`. + */ + private val trails = mutableMapOf() + + fun trail(side: Side): String = trails[side] ?: current(side).path + + /** The paths this pane has been to, newest first — the 展开 list beside the star. */ + private val recents = mutableMapOf>() + + fun recent(side: Side): List = synchronized(recents) { + recents[side]?.toList().orEmpty() + } + + private fun noteRecent(side: Side, backendId: String, path: String) { + if (backendId != current(side).backendId || path.isBlank()) return + synchronized(recents) { + val list = recents.getOrPut(side) { ArrayDeque() } + list.remove(path) + list.addFirst(path) + while (list.size > 20) list.removeLast() + } + } + + private fun extendTrail(side: Side, backendId: String, path: String) { + val before = current(side) + if (before.backendId != backendId || path.isBlank()) { + trails.remove(side) + if (path.isNotBlank()) trails[side] = path + return + } + val trail = trails[side] ?: before.path.ifEmpty { path } + trails[side] = when { + path.startsWith("$trail/") -> path + trail.startsWith("$path/") || trail == path -> trail + else -> path + } + } + + /** The crumbs for a pane: its whole trail, with the current layer marked by the caller. */ + fun crumbs(side: Side): List = Browsing.crumbs(trail(side)) + /** Points a pane at a folder and lists it. */ fun open(side: Side, backendId: String = current(side).backendId, path: String) { val generation = nextGeneration(side) val before = current(side) + extendTrail(side, backendId, path) + noteRecent(side, backendId, path) set( side, before.copy( diff --git a/service/src/main/kotlin/io/github/cubsync/service/IconPaths.kt b/service/src/main/kotlin/io/github/cubsync/service/IconPaths.kt index 743f433..5bffd6d 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/IconPaths.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/IconPaths.kt @@ -57,6 +57,9 @@ object IconPaths { const val REFRESH = "M20.5 11.5a8.5 8.5 0 1 0-2.4 5.9M20.5 5v6.5H14" const val CLOSE = "M6 6l12 12M18 6L6 18" const val PLUS = "M12 5v14M5 12h14" + + /** 排序: three rows of different lengths with a handle, which is what a drag does. */ + const val SORT = "M4 7h9M4 12h6M4 17h3M17 5v14M14.5 16.5L17 19l2.5-2.5" const val MINUS = "M5 12h14" const val CHECK = "M5 12.5l4.5 4.5L19 7.5" const val CHEVRON_DOWN = "M6 9.5l6 6 6-6" diff --git a/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt b/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt index b4fc12a..1b6230c 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt @@ -23,9 +23,22 @@ data class Favorite( * reachable two ways it is the protocol that says which one. */ val protocol: String = if (backendId == EndpointId.LOCAL) "本端" else "CubSync", + /** + * The drawer group this row sits in, once somebody has organised the list. + * + * Empty means "file it under its end", which is what a favourite does when it is + * created: 本端, then one heading per peer and protocol. A named group is a list the + * person made, and it stays where they put it. + */ + val groupName: String = "", ) { - /** The heading this favourite belongs under. */ - val group: String get() = if (backendId == EndpointId.LOCAL) Words.LOCAL else "$device · $protocol" + /** Stable identity: the same folder on the same backend is the same favourite. */ + val key: String get() = "$backendId|$path" + + /** The heading this favourite belongs under when it has not been filed by hand. */ + val autoGroup: String get() = if (backendId == EndpointId.LOCAL) Words.LOCAL else "$device · $protocol" + + val group: String get() = groupName.ifEmpty { autoGroup } } data class FavoriteGroup(val device: String, val items: List)