diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce2929..3fdc1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ with the pre-1.0 rules described in [docs/VERSIONING.md](docs/VERSIONING.md). ## [Unreleased] +## [0.3.13] - 2026-10-17 + +### Fixed + +- Desktop: 显示方式 / right-click overlays no longer eat the pane area; the card hangs under its + button and the five modes keep their marks. +- Desktop: notices are drawn over the window instead of taking a row of their own. +- Desktop: a pane takes focus when clicked, and 显示方式 acts on the focused pane. +- Desktop favourites: grouped by 本端 / 远端 and protocol, opened in the focused pane, deletable, + and `+` adds an address rather than only the current view. +- Desktop: USB works through `adb forward`; the address box is split into host and port with the + protocol's port as the default. +- Accent: one list on both ends, the first chip is the brand indigo called 默认; the broken + 「回到品牌靛蓝」 button is gone. +- Android: the system back button closes panels, leaves screens, returns to 传输, then goes up one + folder, and only then exits. +- Android: 停止共享 updates the card; breadcrumbs show every layer and scroll to the current one. +- Android: 分栏方向 uses the real screen size, so 自动 is two columns in landscape. +- Android: no root, ← stops at the exposed root instead of climbing to `/`. +- Android: 连接另一台设备 is a visible card with host and port; connecting opens the remote pane on + the first shared folder, and an unshared path lists what is shared instead of 读不了这个文件夹. +- 本机 → 本端 throughout: the far end may be another computer, another disk, or a WebDAV mount. + ## [0.3.12] - 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 afa0808..bb8abd3 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 @@ -1,5 +1,6 @@ package io.github.cubsync.android +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -55,6 +56,11 @@ import kotlinx.coroutines.delay fun CubAndroidApp(state: AndroidAppState) { val colors = CubTheme.colors val route = state.route + state.revision + + // Back undoes the last thing, and only leaves when there is nothing left to undo. + // Without this every screen exited the application, including a folder. + BackHandler(enabled = state.canGoBack) { state.handleBack() } Box(Modifier.fillMaxSize().background(colors.background)) { // The system bars sit over the app (MainActivity calls enableEdgeToEdge), so the @@ -341,11 +347,12 @@ private fun Drawer(state: AndroidAppState) { detail = item.path, leadingIcon = IconPaths.FOLDER, modifier = Modifier.padding(horizontal = 8.dp), - onClick = { - state.drawerOpen = false - state.go(Section.TRANSFER) - state.openPane(io.github.cubsync.service.Side.LEFT, item.path, item.backendId) + trailing = { + IconButton(IconPaths.CLOSE, size = 30, mark = 13) { + state.removeFavourite(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 7a5336a..e388fe6 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 @@ -116,6 +116,8 @@ class AndroidAppState( var peers by mutableStateOf>(emptyList()) var peerError by mutableStateOf(null) var peerDraft by mutableStateOf("") + var peerHost by mutableStateOf("") + var peerPortText by mutableStateOf("") var connecting by mutableStateOf(false) var running by mutableStateOf(null) @@ -180,7 +182,11 @@ class AndroidAppState( usableRoot()?.let { service.settings.sharedRoots = listOf(it) } } rootDraft = service.settings.sharedRoots - rootDraft = service.settings.sharedRoots + + // Tell the backend what this phone can actually open. Without this the local + // backend exposes no roots, so 上一层 had no boundary to stop at and walked from + // /storage/emulated/0 up to / — three levels the process cannot read. + localRoots().forEach { service.browsePool.registerLocalRoot(it) } } fun close() { @@ -218,9 +224,20 @@ class AndroidAppState( * cannot race one the caller asked for. */ private fun openSomethingToLookAt() { - if (service.browsing.current(Side.LEFT).path.isNotBlank()) return - (rootDraft.firstOrNull() ?: usableRoot())?.let { - service.browsing.open(Side.LEFT, EndpointId.LOCAL, it) + val left = service.browsing.current(Side.LEFT) + if (left.path.isBlank() || left.backendId != EndpointId.LOCAL) { + (rootDraft.firstOrNull() ?: usableRoot())?.let { + service.browsing.open(Side.LEFT, EndpointId.LOCAL, it) + } + } + // The right pane is 远端. When a peer is connected it opens on that peer rather + // than on this phone's own disk, which is what made 远端栏 show "/" and then refuse + // a path from the other machine: it was still the local backend. + 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()) + } } } @@ -232,6 +249,52 @@ class AndroidAppState( route = Route.Tabs } + /** + * The system back button, in the order a person would undo things. + * + * Android's back used to leave the application from every screen, so a folder entered + * in 传输 closed CubSync instead of going back to the folder. Panels close, then the + * screen, then the section, then one folder up — and only when there is nothing left to + * undo does the system get the event and finish the activity. + */ + fun handleBack() { + when { + drawerOpen -> drawerOpen = false + moreSheet -> moreSheet = false + connectSheet -> connectSheet = false + sharedSheet -> sharedSheet = false + screenMenu -> screenMenu = false + paneMenu != null -> paneMenu = null + actionSheet != null -> actionSheet = null + route !is Route.Tabs -> route = Route.Tabs + tab != Section.TRANSFER -> go(Section.TRANSFER) + else -> { + val side = liveSettings.activeSide + service.browsing.up(side) + touch() + } + } + } + + /** Whether back has something to undo; when it does not, the application closes. */ + val canGoBack: Boolean + get() { + if (drawerOpen || moreSheet || connectSheet || sharedSheet || screenMenu || + paneMenu != null || actionSheet != null || route !is Route.Tabs || + tab != Section.TRANSFER + ) { + return true + } + val side = liveSettings.activeSide + val pane = service.browsing.current(side) + if (pane.path.isBlank()) return false + val roots = service.browsing.exposedRoots(pane.backendId) + val root = roots.filter { pane.path == it || pane.path.startsWith("$it/") } + .maxByOrNull { it.length } + // At the exposed root there is no parent to show, so back means "leave". + return root == null || pane.path != root + } + val selectedConfig: SyncConfig? get() = selectedConfigId?.let { id -> configs.firstOrNull { it.configId == id } } @@ -434,6 +497,20 @@ class AndroidAppState( fun drawer(): DrawerModel = service.drawer(serverState) + /** Opens a favourite in the focused pane, which is where the next tap would go. */ + fun openFavourite(favourite: Favorite) { + drawerOpen = false + go(Section.TRANSFER) + service.browsing.open(liveSettings.activeSide, favourite.backendId, favourite.path) + touch() + } + + fun removeFavourite(favourite: Favorite) { + service.settings.removeFavourite(favourite) + revision++ + notify("已取消收藏 ${favourite.path}", NoticeKind.INFO) + } + /** 收藏 a configuration — the star in the top bar of its screen. */ fun toggleConfigStar() { val config = selectedConfig ?: return @@ -612,6 +689,23 @@ class AndroidAppState( // ---------------------------------------------------------------------- peers + /** A host and a port typed apart; an empty port means the protocol default. */ + fun connectPeerAddress() { + val host = peerHost.trim() + if (host.isEmpty()) { + peerError = "先填地址" + return + } + val port = peerPortText.trim().let { + if (it.isEmpty()) io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT else it.toIntOrNull() + } + if (port == null || port !in 1..65535) { + peerError = "端口要填 1–65535,留空就用默认 ${io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT}" + return + } + connectPeer("$host:$port") + } + fun connectPeer(spec: String) { val text = spec.trim() if (text.isEmpty()) { @@ -631,7 +725,17 @@ class AndroidAppState( peers = peers.filterNot { it.backendId == session.backendId } + session service.settings.lastPeer = text revision++ - notify("已连接 ${session.nodeName}", NoticeKind.SUCCESS) + // 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()) + liveSettings.activeSide = Side.RIGHT + notify( + if (first == null) "已连接 ${session.nodeName},但对面没有共享文件夹" + else "已连接 ${session.nodeName} · ${first}", + if (first == null) NoticeKind.WARNING else NoticeKind.SUCCESS, + ) } catch (failure: Throwable) { peerError = failure.message ?: "连接失败" notify("连接失败:$peerError", NoticeKind.ERROR) @@ -700,16 +804,45 @@ class AndroidAppState( fun stopServer() { server.stop() + // The screen reads `serverState` off the controller, which is not Compose state; + // without this the button stayed 停止共享 and the address stayed on screen after + // the server had already stopped. + revision++ notify("已停止共享", NoticeKind.INFO) } fun setRunMode(mode: RunMode) { changeSettings { it.runMode = mode } - if (mode == RunMode.ROOT) { - notify("已切到 Root 模式:设备需要已获取 Root 权限,普通模式下这一档不会生效", NoticeKind.WARNING) + // Say what is true rather than what the label promises. The two privileged modes + // need a backend that speaks through `su` or Shizuku, and this build does not have + // one — so the setting is recorded, and the sentence beside it says so. + when (mode) { + RunMode.NORMAL -> notify("已切到普通模式", NoticeKind.INFO) + RunMode.ROOT -> notify( + if (suAvailable()) "已记录 Root 模式;本版引擎仍按应用权限读文件,su 只用于探测" + else "没有拿到 Root 权限:su 不可用或被拒绝", + NoticeKind.WARNING, + ) + RunMode.SHIZUKU -> notify("Shizuku 需要 Shizuku 应用与它的 API;本版还没有接入", NoticeKind.WARNING) } } + /** Whether `su` answers with uid 0. A probe, not a promise. */ + private fun suAvailable(): Boolean = runCatching { + val process = ProcessBuilder("su", "-c", "id").redirectErrorStream(true).start() + // Close its input: `su` that wants a password otherwise waits for one, and reading + // its output first blocks the caller forever. That is what hung CI's test step — + // the JVM there has `su` and no root. + runCatching { process.outputStream.close() } + val finished = process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + return@runCatching false + } + process.exitValue() == 0 && + process.inputStream.bufferedReader().use { it.readText() }.contains("uid=0") + }.getOrDefault(false) + // ------------------------------------------------------------ 传输通道 /** The cards 传输通道 offers, taken from the system rather than invented. */ 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 7d0caba..1b119e5 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,7 +48,9 @@ import io.github.cubsync.service.Words @Composable fun ConnectScreen(state: AndroidAppState) { val colors = CubTheme.colors - val server = state.serverState + // Collected, not read off the controller: `serverState` is a plain getter, so the card + // stayed 停止共享 after the server had already stopped. + val server by state.server.state.collectAsState() val settings = state.liveSettings var modeOpen by remember { mutableStateOf(false) } val copy = rememberClipboard() @@ -160,6 +163,35 @@ fun ConnectScreen(state: AndroidAppState) { } } + SectionLabel("连接另一台设备") + Panel(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Field( + value = state.peerHost, + onValueChange = { state.peerHost = it }, + modifier = Modifier.weight(2f), + placeholder = "地址,如 192.168.1.20", + onSubmit = { state.connectPeerAddress() }, + ) + Spacer(Modifier.width(6.dp)) + Field( + value = state.peerPortText, + onValueChange = { state.peerPortText = it }, + modifier = Modifier.weight(1f), + placeholder = "${settings.sharePort}", + onSubmit = { state.connectPeerAddress() }, + ) + } + Spacer(Modifier.height(8.dp)) + PrimaryButton(if (state.connecting) "连接中…" else "连接", enabled = !state.connecting) { + state.connectPeerAddress() + } + state.peerError?.let { + Text(it, style = CubType.rowDetail, color = colors.danger) + } + CardDesc("端口留空就用对面共享时显示的那个;连上后远端栏会打开它共享的第一个目录。") + } + // The prototype's 连接 screen stops after the two buttons and 最近. An // 账户与连接状态 card was added here; accounts are a thing the drawer already // lists, and a screen that says the same thing twice is a screen with one 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 c2bb1a8..39ce9cb 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 @@ -4,8 +4,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -54,6 +58,8 @@ import io.github.cubsync.service.Columns import io.github.cubsync.service.CompareMark import io.github.cubsync.service.Density import io.github.cubsync.service.DisplayMode +import io.github.cubsync.service.EndpointId +import io.github.cubsync.service.Section import io.github.cubsync.service.Format import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable @@ -101,6 +107,12 @@ fun TransferScreen(state: AndroidAppState) { else -> null } + // The real size of the screen, not a guess: 分栏方向 said 自动 while the code compared + // 380 against 560 — two numbers that never change, so a phone held sideways still + // stacked the panes. + BoxWithConstraints(Modifier.fillMaxSize()) { + val widthDp = maxWidth.value + val heightDp = maxHeight.value Column( Modifier .fillMaxSize() @@ -117,11 +129,7 @@ fun TransferScreen(state: AndroidAppState) { if (settings.displayBarOpen) DisplayBar(state) HorizontalDivider(color = colors.outline) - val paneDirection = resolveDirection( - settings.paneDirection, - 380f, - if (solo != null) 700f else 560f, - ) + val paneDirection = resolveDirection(settings.paneDirection, widthDp, heightDp) when { solo != null -> Pane(state, solo, if (solo == Side.LEFT) left else right, compare, Modifier.weight(1f)) @@ -143,6 +151,7 @@ fun TransferScreen(state: AndroidAppState) { // from the selection bar. The desktop keeps its two arrows; this end does not. ActionBar(state) } + } } // --------------------------------------------------------------------- sub row @@ -199,7 +208,8 @@ private fun SubRow(state: AndroidAppState, left: PaneState, right: PaneState, bu ) { CubIcon(viewIcon(prefs.displayMode), size = 16, tint = colors.text) Spacer(Modifier.width(7.dp)) - Text(prefs.displayMode.label, style = CubType.label, color = colors.text, maxLines = 1) + // The prototype's pill reads 显示方式:详细 — the words, not only the mode. + 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, @@ -371,19 +381,31 @@ private fun Pane(state: AndroidAppState, side: Side, pane: PaneState, compare: B .background(if (active) colors.accent.copy(alpha = 0.05f) else Color.Transparent) // Tap anywhere in a pane to focus it: the sub-row's 显示方式 acts on the focused // pane, and a focus you cannot change by touching the pane is a focus that lies. - .pointerInput(side, active) { - awaitEachGesture { - awaitFirstDown(requireUnconsumed = false) - if (!active) { - settings.activeSide = side - state.touch() - } + // A clickable on the container rather than a pointer observer — the observer ran + // after the rows had consumed their presses, so rows never moved the focus. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (settings.activeSide != side) { + settings.activeSide = side + state.touch() } }, ) { PaneHeader(state, side, pane, active) Box(Modifier.weight(1f).fillMaxWidth().itemScaleGestures(state)) { when { + // 远端 with no peer is not an empty folder: it is a pane waiting for a + // connection, and it used to show this phone's own disk instead — which is + // how a path from the other machine ended up being asked of the wrong side. + side == Side.RIGHT && pane.backendId == EndpointId.LOCAL && + pane.path.isBlank() && state.peers.isEmpty() -> + EmptyState( + "还没有连接远端", + "在「连接」里填对面显示的地址,或者让对面先连上这台机器", + action = { QuietButton("去连接") { state.go(Section.CONNECT) } }, + ) pane.error.isNotEmpty() -> EmptyState("读不了这个文件夹", pane.error) // 对比 draws the other side's rows as ghosts, so a pane that has nothing of // its own is not an empty folder — it is a folder where everything will be @@ -456,7 +478,11 @@ private fun PaneHeader(state: AndroidAppState, side: Side, pane: PaneState, acti ) } } else { - val crumbs = Browsing.crumbs(pane.path, maxSegments = if (expanded) 64 else if (lines > 1) 3 else 4) + // Every layer. The old limit folded three of them into one `⋯` even when + // 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) Box( Modifier .weight(1f) @@ -469,58 +495,36 @@ private fun PaneHeader(state: AndroidAppState, side: Side, pane: PaneState, acti }, ) } - // A sideways drag scrolls the address instead of jumping: a swipe - // that lands on a layer was the way this used to navigate by accident. - .pointerInput(pane.path) { - detectHorizontalDragGestures { change, _ -> change.consume() } - } .padding(horizontal = 2.dp, vertical = 4.dp), ) { Column { - if (lines > 1 && crumbs.size > 1) { - // N-1 lines of ancestors, each one a way back up, then the - // folder itself in the reading colour. - for (crumb in crumbs.dropLast(1).takeLast(lines - 1)) { - Text( - tailPath(crumb.path), - style = CubType.meta, - color = colors.textDim, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .clickableCustom { state.openPane(side, crumb.path, pane.backendId) }, - ) + run { + // Scrolls rather than folds: a narrow two-pane header keeps + // every layer reachable, and a wide one shows them all. + val crumbScroll = rememberScrollState() + // Parked at the right end: the folder being shown is the last + // crumb, and a row that starts at the anchor cuts it off. + androidx.compose.runtime.LaunchedEffect(pane.path, crumbScroll.maxValue) { + crumbScroll.scrollTo(crumbScroll.maxValue) } - Text( - crumbs.last().label, - style = CubType.meta.copy(fontSize = 11.5.sp), - color = colors.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickableCustom { - state.openPane(side, crumbs.last().path, pane.backendId) - }, - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(crumbScroll), + verticalAlignment = Alignment.CenterVertically, + ) { for ((index, crumb) in crumbs.withIndex()) { if (index > 0) Text(" › ", style = CubType.meta, color = colors.textFaint) Text( crumb.label, style = CubType.meta.copy(fontSize = 11.5.sp), - color = when { - crumb.collapsed -> colors.accent - index == crumbs.lastIndex -> colors.text - else -> colors.textDim - }, + color = if (index == crumbs.lastIndex) colors.text else colors.textDim, maxLines = 1, - overflow = TextOverflow.Ellipsis, + softWrap = false, modifier = Modifier .clip(RoundedCornerShape(4.dp)) .clickableCustom { - if (crumb.collapsed) expanded = true - else state.openPane(side, crumb.path, pane.backendId) + state.openPane(side, crumb.path, pane.backendId) } .padding(horizontal = 1.dp), ) @@ -622,21 +626,38 @@ private fun PaneList(state: AndroidAppState, side: Side, pane: PaneState, scale: ) } Spacer(Modifier.width((10 * scale).dp)) - Column(Modifier.weight(1f)) { + if (mode == DisplayMode.COMPACT) { + // 紧凑 is one line: the size sits beside the name instead of under it. + // Otherwise it was the same screen as 详细 with a 8dp difference, which + // is what "every mode looks the same" means. Text( Browsing.name(entry.relPath), style = CubType.body.copy(fontSize = fontSize), color = if (entry.isDirectory) colors.text else colors.textDim, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) - if (settings.showMetadata) { + if (settings.showMetadata && !entry.isDirectory) { + Text(Format.bytes(entry.size), style = CubType.meta, color = colors.textFaint, maxLines = 1) + } + } else { + Column(Modifier.weight(1f)) { Text( - if (entry.isDirectory) DASH else "${Format.bytes(entry.size)} · ${Format.stamp(entry.mtimeMs)}", - style = CubType.meta, - color = colors.textFaint, + Browsing.name(entry.relPath), + style = CubType.body.copy(fontSize = fontSize), + color = if (entry.isDirectory) colors.text else colors.textDim, maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + if (settings.showMetadata) { + Text( + if (entry.isDirectory) DASH else "${Format.bytes(entry.size)} · ${Format.stamp(entry.mtimeMs)}", + style = CubType.meta, + color = colors.textFaint, + maxLines = 1, + ) + } } } } diff --git a/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt b/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt index e62bf9e..b026c01 100644 --- a/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt +++ b/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt @@ -116,6 +116,22 @@ class PhoneScreenshotTest { fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.CARD) shot("13-transfer-card", fixture) + // 传输 · 紧凑 / 缩略:五种显示方式必须长得不一样 + fixture.state.setDisplayMode(Side.LEFT, io.github.cubsync.service.DisplayMode.COMPACT) + fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.COMPACT) + shot("15-transfer-compact", fixture) + + fixture.state.setDisplayMode(Side.LEFT, io.github.cubsync.service.DisplayMode.THUMBNAIL) + fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.THUMBNAIL) + shot("16-transfer-thumb", fixture) + + // 副行「显示方式」展开条 + fixture.state.setDisplayMode(Side.LEFT, io.github.cubsync.service.DisplayMode.DETAIL) + fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.DETAIL) + fixture.state.liveSettings.displayBarOpen = true + shot("17-transfer-viewbar", fixture) + fixture.state.liveSettings.displayBarOpen = false + // 副行 ⋮ 的「更多」 fixture.state.setDisplayMode(Side.LEFT, io.github.cubsync.service.DisplayMode.DETAIL) fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.DETAIL) 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 6663e50..f24839b 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 @@ -37,6 +37,7 @@ import io.github.cubsync.service.Words import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext enum class NoticeKind { INFO, SUCCESS, WARNING, ERROR } @@ -112,9 +113,17 @@ class AppState( var peers by mutableStateOf>(emptyList()) var peerError by mutableStateOf(null) + + /** The address box's two halves, kept apart so a port is optional and a colon is not. */ + var peerHost by mutableStateOf("") + var peerPortText by mutableStateOf("") var peerDraft by mutableStateOf("") var connecting by mutableStateOf(false) + /** USB: what adb can see, and the last thing it said. */ + var adbDevices by mutableStateOf>(emptyList()) + var adbNote by mutableStateOf(null) + var running by mutableStateOf(null) var notice by mutableStateOf(null) @@ -163,6 +172,8 @@ class AppState( logs = if (next.size > LOG_LIMIT) next.takeLast(LOG_LIMIT) else next } peerDraft = service.settings.lastPeer + service.settings.lastPeer.substringBeforeLast(':').takeIf { it.isNotEmpty() }?.let { peerHost = it } + service.settings.lastPeer.substringAfterLast(':', "").toIntOrNull()?.let { peerPortText = it.toString() } // A server with nothing shared cannot be started, so the first run seeds the list // with the one folder every machine has. 添加文件夹 changes it afterwards; the // point is that 启动服务端 works before anybody has been taught where to click. @@ -387,11 +398,52 @@ class AppState( service.settings.toggleFavourite(favourite) settingsRevision++ notify( - if (service.settings.isFavourite(device, pane.path)) "已收藏 ${pane.path}" else "已取消收藏", + if (service.settings.isFavourite(pane.backendId, pane.path)) "已收藏 ${pane.path}" else "已取消收藏", NoticeKind.INFO, ) } + /** 收藏夹's `+`: a folder added by address, not only the one a pane happens to show. */ + var favouriteDraft by mutableStateOf(null) + + fun addFavourite(path: String) { + val clean = io.github.cubsync.service.PathTools.normalizeRoot(path.trim()) + if (clean.isEmpty()) return + val side = liveSettings.activeSide + val pane = service.browsing.current(side) + val backendId = if (pane.backendId == EndpointId.LOCAL) EndpointId.LOCAL else pane.backendId + val device = if (backendId == EndpointId.LOCAL) Words.LOCAL else peerFor(backendId)?.nodeName ?: Words.REMOTE + service.settings.toggleFavourite(Favorite(device, backendId, clean)) + // toggle means "add"; if it was already there it has just been removed, so put it + // back — pressing + on a folder that is already starred should not unstar it. + if (!service.settings.isFavourite(backendId, clean)) { + service.settings.toggleFavourite(Favorite(device, backendId, clean)) + } + settingsRevision++ + favouriteDraft = null + notify("已收藏 $clean", NoticeKind.SUCCESS) + } + + fun removeFavourite(favourite: Favorite) { + service.settings.removeFavourite(favourite) + settingsRevision++ + notify("已取消收藏 ${favourite.path}", NoticeKind.INFO) + } + + /** + * Opens a favourite in whichever pane is focused. + * + * It used to always open on the left, so a favourite of a folder on the phone landed in + * 本端栏 and failed. The focused pane is the one the next click would have acted on, + * which is where a person expects it to appear. + */ + fun openFavourite(favourite: Favorite) { + goTo(Section.TRANSFER) + val side = liveSettings.activeSide + service.browsing.open(side, favourite.backendId, favourite.path) + touchBrowsing() + } + fun applyPrefs(side: Side, scope: Scope, change: (io.github.cubsync.service.BrowsePrefs) -> io.github.cubsync.service.BrowsePrefs) { val prefs = service.browsing.prefsOf(side) service.browsing.applyPrefs(side, scope, change(prefs)) @@ -596,6 +648,64 @@ class AppState( // ---------------------------------------------------------------------- peers + /** Looks for devices on the other end of a cable. */ + fun refreshAdb() { + scope.launch(kotlinx.coroutines.Dispatchers.IO) { + val found = io.github.cubsync.service.Adb.devices() + withContext(kotlinx.coroutines.Dispatchers.Main) { + adbDevices = found + adbNote = when { + !io.github.cubsync.service.Adb.available() -> "没有找到 adb:装好 platform-tools 并加进 PATH" + found.isEmpty() -> "没有已连接的 USB 设备" + else -> null + } + } + } + } + + /** + * Connects over the cable: `adb forward` first, then the ordinary TCP handshake. + * + * The phone is already serving on its share port; the cable only changes the route, so + * after the forward this is the same code path as Wi-Fi and the rest of the screen does + * not need to know which one is in use. + */ + fun connectUsb(serial: String?, port: Int) { + val target = if (port <= 0) io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT else port + connecting = true + peerError = null + scope.launch(kotlinx.coroutines.Dispatchers.IO) { + val failure = io.github.cubsync.service.Adb.forward(target, target, serial) + withContext(kotlinx.coroutines.Dispatchers.Main) { + connecting = false + if (failure != null) { + adbNote = failure + peerError = failure + } else { + adbNote = "已转发 tcp:$target → 设备 tcp:$target" + connectPeer("127.0.0.1:$target") + } + } + } + } + + /** The address box: a host, and a port that defaults to the protocol's. */ + fun connectPeerAddress() { + val host = peerHost.trim() + if (host.isEmpty()) { + peerError = "先填地址" + return + } + val port = peerPortText.trim().let { + if (it.isEmpty()) io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT else it.toIntOrNull() + } + if (port == null || port !in 1..65535) { + peerError = "端口要填 1–65535,留空就用默认 ${io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT}" + return + } + connectPeer("$host:$port") + } + fun connectPeer(spec: String) { val text = spec.trim() if (text.isEmpty()) { diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt index 6fb323d..f4ff8e3 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt @@ -87,9 +87,13 @@ fun ConnectScreen(state: AppState, server: ServerState) { private fun LinkCard(state: AppState, modifier: Modifier) { val colors = CubTheme.colors var mode by remember { mutableStateOf(LinkMode.ADB) } - val devices = state.peers + val settings = state.liveSettings - Card(modifier, title = "连接方式", sub = "USB 需 adb 在 PATH") { + // Ask once, on the frame the card first appears, so the list is not empty until the + // person presses 刷新 for no reason. + androidx.compose.runtime.LaunchedEffect(Unit) { state.refreshAdb() } + + Card(modifier, title = "连接方式", sub = if (io.github.cubsync.service.Adb.available()) "adb 已就绪" else "USB 需要 adb 在 PATH") { Row( Modifier .clip(RoundedCornerShape(8.dp)) @@ -102,10 +106,7 @@ private fun LinkCard(state: AppState, modifier: Modifier) { Modifier .weight(1f) .background(if (active) colors.accent else Color.Transparent) - .clickable { - mode = option - state.notify(Channels.ADB_RESERVED, NoticeKind.INFO) - } + .clickable { mode = option } .padding(vertical = 7.dp), contentAlignment = Alignment.Center, ) { @@ -119,42 +120,67 @@ private fun LinkCard(state: AppState, modifier: Modifier) { } Spacer(Modifier.height(14.dp)) - FieldLabel("USB 设备") - Spacer(Modifier.height(6.dp)) - Box( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(colors.surface) - .border(1.dp, colors.outline, RoundedCornerShape(8.dp)) - .clickable { state.notify(Channels.ADB_RESERVED, NoticeKind.INFO) } - .padding(horizontal = 10.dp, vertical = 7.dp), - ) { + if (mode == LinkMode.ADB) { + Row(verticalAlignment = Alignment.CenterVertically) { + FieldLabel("USB 设备") + Spacer(Modifier.weight(1f)) + QuietButton("刷新") { state.refreshAdb() } + } + Spacer(Modifier.height(6.dp)) + val ready = state.adbDevices.filter { it.ready } + if (ready.isEmpty()) { + Text( + state.adbNote ?: "没有已连接的 USB 设备", + style = CubType.body, + color = colors.textFaint, + ) + } + for (device in ready) { + Row( + Modifier.fillMaxWidth().padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(device.label, style = CubType.body, color = colors.text, modifier = Modifier.weight(1f)) + QuietButton(if (state.connecting) "连接中…" else "连接并协商") { + // The phone's port, not this machine's: empty means the protocol + // default, which is what a phone shares on unless it was changed. + state.connectUsb(device.serial, state.peerPortText.toIntOrNull() ?: 0) + } + } + } + state.adbNote?.takeIf { ready.isNotEmpty() }?.let { + Spacer(Modifier.height(4.dp)) + Text(it, style = CubType.tiny, color = colors.textFaint) + } + Spacer(Modifier.height(8.dp)) Text( - if (devices.isEmpty()) "没有已连接的 USB 设备" else devices.joinToString(" · ") { it.nodeName }, - style = CubType.body, - color = if (devices.isEmpty()) colors.textFaint else colors.text, + "USB 走的是 adb 转发:把设备上的端口(默认 ${settings.sharePort})映射到本机同名端口,之后与局域网是同一条连接。", + style = CubType.tiny, + color = colors.textFaint, ) - } - - Spacer(Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - QuietButton("启动 ADB 转发 :5740", Modifier.weight(1f)) { - state.notify(Channels.ADB_RESERVED, NoticeKind.INFO) + } else { + FieldLabel("地址") + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Field( + value = state.peerHost, + onValueChange = { state.peerHost = it }, + modifier = Modifier.weight(2f), + placeholder = "192.168.1.20", + onSubmit = { state.connectPeerAddress() }, + ) + Field( + value = state.peerPortText, + onValueChange = { state.peerPortText = it }, + modifier = Modifier.weight(1f), + placeholder = "端口(默认 ${settings.sharePort})", + onSubmit = { state.connectPeerAddress() }, + ) } - PrimaryButton("连接并协商", Modifier.weight(1f)) { state.connectPeer(state.peerDraft) } + Spacer(Modifier.height(8.dp)) + PrimaryButton(if (state.connecting) "连接中…" else "连接并协商") { state.connectPeerAddress() } } - Spacer(Modifier.height(12.dp)) - FieldLabel("HOST:PORT") - Spacer(Modifier.height(6.dp)) - Field( - value = state.peerDraft, - onValueChange = { state.peerDraft = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = "192.168.1.20:43817", - onSubmit = { state.connectPeer(state.peerDraft) }, - ) state.peerError?.let { Spacer(Modifier.height(6.dp)) Text(it, style = CubType.tiny, color = colors.danger) diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/SettingsScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/SettingsScreen.kt index 8e82f52..bbdff7f 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/SettingsScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/SettingsScreen.kt @@ -192,30 +192,34 @@ private fun AppearancePanel(state: AppState) { ) } - SettingRow("取色", "强调色用在选中档、聚焦栏与进度上") { + SettingRow("取色", "强调色用在选中档、聚焦栏与进度上;第一个是默认") { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { for (swatch in ACCENTS) { - Box( - Modifier - .size(24.dp) - .clip(CircleShape) - .background(parseHexColour(swatch)) - .border( - 2.dp, - if (settings.accent.equals(swatch, ignoreCase = true)) colors.text else Color.Transparent, - CircleShape, - ) - .clickable { state.changeSettings { it.accent = swatch } }, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier + .size(24.dp) + .clip(CircleShape) + .background(parseHexColour(swatch.hex)) + .border( + 2.dp, + if (settings.accent.equals(swatch.hex, ignoreCase = true)) colors.text else Color.Transparent, + CircleShape, + ) + .clickable { state.changeSettings { it.accent = swatch.hex } }, + ) + Spacer(Modifier.height(2.dp)) + // The phone's chips carry their names, so these do too — and the + // first one is called 默认 on both, because that is what it is. + Text(swatch.label, style = CubType.tiny, color = colors.textFaint) + } } - Spacer(Modifier.width(4.dp)) - QuietButton("回到品牌靛蓝") { state.changeSettings { it.accent = AppSettings.ACCENT_DEFAULT } } } } Spacer(Modifier.height(6.dp)) Text( - "品牌主色 ${AppSettings.ACCENT_DEFAULT},辅色 #1FA8A0。换成别的颜色是允许的,但标志和界面会开始不像一套。", + "默认 ${AppSettings.ACCENT_DEFAULT} 是标志的靛蓝;辅色 ${io.github.cubsync.service.Brand.ACCENT}。换成别的颜色是允许的,但标志和界面会开始不像一套。", style = CubType.meta, color = colors.textFaint, ) @@ -256,8 +260,13 @@ private fun AboutPanel(state: AppState) { } } -/** The accents offered. The first is the brand's; the rest are there so it can be changed. */ -private val ACCENTS = listOf("#3A5CCC", "#1FA8A0", "#7A5AF8", "#D9480F", "#2E9E5B") +/** + * The accents offered — the phone's list, verbatim. + * + * Two lists meant two first chips: the desktop began with `#3A5CCC` and called it 靛蓝, the + * phone began with `#4C6EF5`, and 回到品牌靛蓝 set a third. One list, one name, one colour. + */ +private val ACCENTS = io.github.cubsync.service.Accent.SWATCHES /** * 设置's `.chead .acts`. 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 067cc73..45e5fce 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 @@ -71,6 +71,7 @@ fun CubApp(state: AppState, chrome: WindowChrome) { val server by state.server.state.collectAsState() var railFolded by remember { mutableStateOf(false) } + Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().background(CubTheme.colors.surface)) { TitleBar(state, server, chrome) Row(Modifier.fillMaxWidth().weight(1f)) { @@ -90,7 +91,41 @@ fun CubApp(state: AppState, chrome: WindowChrome) { } } StatusBar(state, server) - NoticeBar(state) + } + // Drawn over the window rather than under the status bar. As a child of the column it + // took a row of its own, so every notice pushed the whole screen up and left a band of + // empty surface below it — the window resized itself to say one sentence. + NoticeBar(state) + + // 收藏夹'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 } + } + } + } + } } } @@ -233,36 +268,44 @@ private fun Rail(state: AppState, server: ServerState, folded: Boolean, onFold: verticalAlignment = Alignment.CenterVertically, ) { Text(Words.FAVOURITES, style = CubType.railLabel, color = colors.textFaint, modifier = Modifier.weight(1f)) - IconButton(IconPaths.PLUS, size = 28, mark = 13) { state.toggleFavourite(Side.LEFT) } + IconButton(IconPaths.PLUS, size = 28, mark = 13) { state.favouriteDraft = "" } } val favourites = state.service.settings.favourites Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - for (group in favourites.groupBy { it.device }.toSortedMap()) { + // 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( - group.key, + heading, style = CubType.tiny, color = colors.textFaint, modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 3.dp), ) - for (favourite in group.value) { + for (favourite in items) { Row( Modifier .fillMaxWidth() + .padding(end = 4.dp) .clip(RoundedCornerShape(6.dp)) - .clickable { - state.goTo(Section.TRANSFER) - state.openPane(Side.LEFT, favourite.path, favourite.backendId) - } - .padding(start = 20.dp, end = 10.dp, top = 5.dp, bottom = 5.dp), + .clickable { state.openFavourite(favourite) } + .padding(start = 20.dp, end = 2.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( - favourite.path, - style = CubType.monoSmall, + 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) + } } } } @@ -458,9 +501,10 @@ private fun NoticeBar(state: AppState) { val notice = state.notice ?: return val colors = CubTheme.colors val tone = colourFor(notice, colors) - Box(Modifier.fillMaxWidth().padding(bottom = 46.dp), contentAlignment = Alignment.Center) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { Row( Modifier + .padding(bottom = 40.dp) .clip(RoundedCornerShape(9.dp)) .background(colors.text) .clickable { state.dismissNotice() } 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 7ca155d..36d28f6 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 @@ -1,6 +1,9 @@ package io.github.cubsync.desktop.ui import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -102,6 +105,7 @@ 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()) { 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 @@ -124,6 +128,10 @@ fun TransferScreen(state: AppState) { } TransferBar(state) + } + // Over the screen, not in it: as the last child of the column the overlay claimed + // the rest of the height, so opening 显示方式 or a right-click panel squeezed both + // panes to nothing and the files appeared to vanish. Overlays(state) } } @@ -188,11 +196,16 @@ private fun Pane(state: AppState, side: Side, pane: PaneState, compare: Boolean, Column( modifier .fillMaxHeight() - .background(if (active) colors.accent.copy(alpha = 0.04f) else Color.Transparent) + .background(if (active) colors.accent.copy(alpha = 0.07f) else Color.Transparent) // Pressing anywhere in a pane makes it the active one, which is what the - // heading's 显示方式 button says it acts on — and the observer is on Press - // rather than on a click, so it does not consume the row that was clicked. - .onPointerEvent(PointerEventType.Press) { + // heading's 显示方式 button says it acts on. A clickable on the container rather + // than a raw pointer observer: the observer ran after the rows had consumed + // their presses, so clicking a row never moved the focus and the two panes + // could not be switched at all. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { if (!active) { settings.activeSide = side state.touchBrowsing() @@ -275,12 +288,16 @@ private fun PaneHeader(state: AppState, side: Side, pane: PaneState, active: Boo private fun Breadcrumbs(state: AppState, side: Side, pane: PaneState) { val colors = CubTheme.colors if (pane.path.isEmpty()) return - val crumbs = Browsing.crumbs(pane.path, maxSegments = 5) - var expanded by remember(pane.path) { mutableStateOf(false) } - val shown = if (expanded) Browsing.crumbs(pane.path, maxSegments = 64) else crumbs + // Every layer, always. A folded `⋯` that still hides three of them reads as "the + // address has one parent", which is what it looked like when the default limit was + // five segments; a row that runs out of width scrolls instead. + val shown = Browsing.crumbs(pane.path) Row( - Modifier.fillMaxWidth().padding(start = 10.dp, end = 8.dp, bottom = 5.dp), + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(start = 10.dp, end = 8.dp, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically, ) { for ((index, crumb) in shown.withIndex()) { @@ -288,18 +305,12 @@ private fun Breadcrumbs(state: AppState, side: Side, pane: PaneState) { Text( crumb.label, style = CubType.meta.copy(fontSize = 11.sp), - color = when { - crumb.collapsed -> colors.accent - index == shown.lastIndex -> colors.text - else -> colors.textDim - }, + color = if (index == shown.lastIndex) colors.text else colors.textDim, maxLines = 1, - overflow = TextOverflow.Ellipsis, + softWrap = false, modifier = Modifier .clip(RoundedCornerShape(4.dp)) - .clickable { - if (crumb.collapsed) expanded = true else state.openPane(side, crumb.path, pane.backendId) - } + .clickable { state.openPane(side, crumb.path, pane.backendId) } .padding(horizontal = 2.dp), ) } @@ -812,9 +823,10 @@ private fun Overlays(state: AppState) { state.displayMenu = false } if (displayMenu) { - // Anchored where the prototype anchors it: under the button that opened it, at - // the right end of the heading — which is where that button is. - Box(Modifier.fillMaxSize().padding(top = 112.dp, end = 22.dp), contentAlignment = Alignment.TopEnd) { + // Under the button that opened it: the heading's 显示方式 sits at the top-right + // of the screen, so the card hangs from the same corner. It used to be pushed + // 112dp down for no reason a reader could see. + Box(Modifier.fillMaxSize().padding(top = 6.dp, end = 14.dp), contentAlignment = Alignment.TopEnd) { DisplayModeMenuCard(state) } } @@ -864,7 +876,7 @@ private fun DisplayModeMenuCard(state: AppState) { CubIcon( viewIcon(mode), size = 16, - tint = if (active) colors.accent else colors.textDim, + tint = if (active) colors.accent else colors.text, ) Spacer(Modifier.width(10.dp)) Text( diff --git a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt index abbc38e..3a01232 100644 --- a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt +++ b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt @@ -78,6 +78,7 @@ object Preview { val dark: Boolean, val jobDetail: Boolean = false, val view: DisplayMode? = null, + val displayMenu: Boolean = false, ) val shots: List = listOf( @@ -91,6 +92,9 @@ object Preview { Shot("07-transfer-light", Section.TRANSFER, dark = false), Shot("08-transfer-compare", Section.TRANSFER, dark = true, view = DisplayMode.COMPARE), Shot("09-transfer-card", Section.TRANSFER, dark = false, view = DisplayMode.CARD), + Shot("10-transfer-viewmenu", Section.TRANSFER, dark = true, displayMenu = true), + Shot("11-transfer-compact", Section.TRANSFER, dark = true, view = DisplayMode.COMPACT), + Shot("12-transfer-thumb", Section.TRANSFER, dark = true, view = DisplayMode.THUMBNAIL), ) /** @@ -100,7 +104,13 @@ object Preview { * started by [AppState] has finished by the time the frame is composed. A screenshot * test that sleeps to wait for its own data is one that fails on a slow machine. */ - fun state(section: Section, dark: Boolean, jobDetail: Boolean = false, view: DisplayMode? = null): AppState { + fun state( + section: Section, + dark: Boolean, + jobDetail: Boolean = false, + view: DisplayMode? = null, + displayMenu: Boolean = false, + ): AppState { val directory = Files.createTempDirectory("cubsync-preview-") val log = AppLog() val desktop = DesktopSettings.inStateDirectory(directory) @@ -140,6 +150,7 @@ object Preview { state.setDisplayMode(Side.RIGHT, view) } state.browsing.select(Side.LEFT, listOf("IMG_20261016_0731.jpg", "IMG_20261016_0730.jpg")) + state.displayMenu = displayMenu return state } diff --git a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt index 2ef76db..7b6bda3 100644 --- a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt +++ b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt @@ -32,7 +32,7 @@ class ScreenshotTest { val written = ArrayList() for (shot in Preview.shots) { - val state = Preview.state(shot.section, shot.dark, shot.jobDetail, shot.view) + val state = Preview.state(shot.section, shot.dark, shot.jobDetail, shot.view, shot.displayMenu) try { val scene = ImageComposeScene(width = WIDTH, height = HEIGHT, density = Density(1f)) { CubTheme(dark = shot.dark) { CubApp(state, Preview.chrome) } diff --git a/docs/releases/v0.3.13.md b/docs/releases/v0.3.13.md new file mode 100644 index 0000000..d29cdd7 --- /dev/null +++ b/docs/releases/v0.3.13.md @@ -0,0 +1,90 @@ +# CubSync 0.3.13 + +## 下载 + +- **Windows**:`CubSync-0.3.13.msi`(安装)或 `cubsync-0.3.13-windows.zip`(解压即用) +- **Android**:`cubsync-0.3.13-android.apk` + +## 这一版修好的 + +**桌面** + +- 打开「显示方式」或右键菜单时,两栏的文件不再消失。此前弹层占掉了整个内容区,看起来像文件被清空了。 +- 「显示方式」弹层贴在按钮下方,不再飘到屏幕中间;五个档位都有图标。 +- 通知条不再把整屏顶上去、在底部留一块空白。 +- 点击任一栏即可切换焦点,被聚焦的一栏有明显的底色与边框;「显示方式」作用在聚焦的那一栏。 +- 收藏夹按 **本端 / 远端**(以及远端协议)自动归类;点击在**当前聚焦**的那一栏打开,不再一律塞进左栏。 +- 收藏夹可以删除;`+` 改成按地址添加任意目录,不再只能收藏当前这一栏。 +- 「USB」连接真的能用了:列出 adb 设备,用 `adb forward` 把设备端口映射到本机后按普通连接协商。之前这个按钮只会弹一句「已留位」。 +- 地址与端口分成两个输入框,端口留空就用对面显示的端口;不用再记住 `HOST:PORT` 的写法。 +- 取色:第一个色块是标志的靛蓝,两端都叫「默认」,五个色块都带名称;「回到品牌靛蓝」按钮已删——它设的是第三种颜色。 + +**手机** + +- 返回键按「关面板 → 回上一屏 → 回传输 → 上一层目录」的顺序退,只有真的没得退时才退出应用。 +- 点「停止共享」后界面立刻恢复成「启动服务端」,不再停在旧状态。 +- 面包屑显示**所有层**,每一层都能点;宽度不够时左右滑动,不再折叠成省略号,打开时自动停在当前目录那一端。 +- 分栏方向按屏幕真实尺寸判断:横屏时「自动」会变成左右两栏。 +- 副行的显示方式按钮改成长条「显示方式 · 详细」,与原型一致。 +- 没有 Root 时,← 到 `/storage/emulated/0` 就停住,不再一路退到不可读的 `/`。 +- 连接页直接有「连接另一台设备」:地址与端口两个输入框,不用再翻 ⋮ 菜单。 +- 连上之后远端栏会打开对面共享的第一个目录;没连接时远端栏显示「还没有连接远端」,不再把本机磁盘当成远端。 +- 在远端栏输入对面机器的绝对路径时,如果该路径不在对面共享范围内,会直接列出对面共享了哪些目录,而不是报「读不了这个文件夹」。 +- 「本机」统一改称「本端」——远端可以是另一台电脑、另一块硬盘,或者以后的 WebDAV / FTP。「账户」一栏按协议列出每一端。 + +## 仍然没有的 + +- **Root / Shizuku** 两种运行模式还只是记录设置:它们需要一个走 `su` 或 Shizuku 的独立后端,本版没有。选了会如实说明,不会假装生效。 +- **WebDAV / FTP / SFTP / SMB** 这些远端协议尚未实现;收藏夹的归类已经按协议留好位置。 + +--- + +# CubSync 0.3.13 + +## Downloads + +- **Windows**: `CubSync-0.3.13.msi`, or `cubsync-0.3.13-windows.zip` to run without installing +- **Android**: `cubsync-0.3.13-android.apk` + +## Fixed + +**Desktop** + +- Opening 显示方式 or a context menu no longer empties both panes — the overlay used to take the + whole content area, so the files looked deleted. +- The 显示方式 card hangs under its button instead of floating in the middle of the window, and all + five modes have their marks. +- A notice no longer pushes the screen up and leaves a blank band at the bottom. +- Clicking a pane switches focus, the focused pane is visibly tinted and outlined, and 显示方式 + applies to it. +- Favourites are grouped by 本端 / 远端 (and by remote protocol), and open in the **focused** pane + instead of always on the left. +- Favourites can be deleted; `+` adds any folder by address rather than only the current view. +- USB works: it lists adb devices, forwards the device's port to this machine, then connects as + usual. The button used to say 已留位. +- Address and port are two fields; leave the port empty to use the one the other side shows. +- 取色: the first chip is the mark's indigo, both ends call it 默认, every chip has its name, and + the 回到品牌靛蓝 button — which set a third colour — is gone. + +**Android** + +- Back closes a panel, then a screen, then returns to 传输, then goes up one folder, and only exits + when there is nothing left. +- 停止共享 updates the screen immediately. +- Breadcrumbs show every layer, each one tappable, scrolling sideways when narrow instead of + folding into an ellipsis, parked at the current folder. +- 分栏方向 uses the real screen size, so 自动 turns into two columns in landscape. +- The display-mode pill reads 显示方式 · 详细, as the prototype draws it. +- Without root, ← stops at /storage/emulated/0 instead of climbing to an unreadable /. +- The connect screen has 连接另一台设备 with address and port fields, not just a hidden menu. +- After connecting, the remote pane opens on the first folder the other side shares; with no peer it + says 还没有连接远端 instead of showing this phone's own disk. +- Typing the other machine's absolute path lists what it actually shares rather than 读不了这个文件夹. +- 本机 is now 本端 throughout: the far end may be another computer, another disk, or a WebDAV / FTP + mount later. 账户 lists each end with its protocol. + +## Still missing + +- **Root / Shizuku** run modes only record a setting: both need a backend that speaks through `su` + or Shizuku, and this build has none. Selecting them says so rather than pretending. +- **WebDAV / FTP / SFTP / SMB** are not implemented; favourite grouping already has a place for them. diff --git a/gradle.properties b/gradle.properties index 4e360b8..e4fa40b 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.12 +cubsync.version=0.3.13 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/Adb.kt b/service/src/main/kotlin/io/github/cubsync/service/Adb.kt new file mode 100644 index 0000000..6773568 --- /dev/null +++ b/service/src/main/kotlin/io/github/cubsync/service/Adb.kt @@ -0,0 +1,101 @@ +package io.github.cubsync.service + +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * 传输通道's USB leg, through the adb that is on the PATH. + * + * The channel was drawn for four releases and said 已留位 when tapped. It is not a special + * protocol: `adb forward` opens a TCP port on this machine that is answered on the device, + * so a USB cable becomes the same socket the Wi-Fi path uses and nothing above this file + * has to know the difference. That is also why the phone needs nothing: it is already + * listening on its share port, and the cable only decides how the host reaches it. + * + * Everything here shells out to the platform tool rather than reimplementing its protocol, + * and every failure is returned as a sentence rather than thrown, because "no adb" is the + * normal state of a machine that has never installed it. + */ +object Adb { + + /** One device as `adb devices -l` describes it. */ + data class Device(val serial: String, val model: String, val state: String) { + val ready: Boolean get() = state == "device" + val label: String get() = when { + model.isNotBlank() -> "$model($serial)" + else -> serial + } + } + + /** Whether an adb that answers is on the PATH. */ + fun available(): Boolean = run("version").first == 0 + + /** Every device the tool can see, including the ones that are unauthorized. */ + fun devices(): List { + val (code, output) = run("devices", "-l") + if (code != 0) return emptyList() + return output.lineSequence() + .drop(1) + .mapNotNull { line -> + val parts = line.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + if (parts.size < 2) return@mapNotNull null + val serial = parts[0] + val state = parts[1] + val model = parts.firstOrNull { it.startsWith("model:") } + ?.removePrefix("model:") + ?.replace('_', ' ') + .orEmpty() + Device(serial, model, state) + } + .toList() + } + + /** + * Makes `127.0.0.1:localPort` on this machine reach `remotePort` on the device. + * + * Returns null when it worked, or the reason it did not. `-s` picks the device when + * more than one is plugged in; without it adb refuses, which is the correct answer. + */ + fun forward(localPort: Int, remotePort: Int = localPort, serial: String? = null): String? { + val args = buildList { + if (!serial.isNullOrBlank()) { + add("-s") + add(serial) + } + add("forward") + add("tcp:$localPort") + add("tcp:$remotePort") + } + val (code, output) = run(*args.toTypedArray()) + return if (code == 0) null else output.trim().ifEmpty { "adb forward 失败(退出码 $code)" } + } + + fun removeForward(localPort: Int) { + run("forward", "--remove", "tcp:$localPort") + } + + private fun run(vararg args: String): Pair = runCatching { + val process = ProcessBuilder(listOf("adb") + args) + .redirectErrorStream(true) + .start() + runCatching { process.outputStream.close() } + if (!process.waitFor(8, TimeUnit.SECONDS)) { + process.destroyForcibly() + return@runCatching -1 to "adb 没有在 8 秒内回答" + } + // Read after it exits: a tool that waits on stdin would otherwise hold the reader + // open forever, which is a hang rather than an error. + process.exitValue() to process.inputStream.bufferedReader().use { it.readText() } + }.getOrElse { failure -> + -1 to (failure.message ?: "找不到 adb") + } + + /** Where the tool lives, for the screen that says whether it was found. */ + fun location(): String? = runCatching { + System.getenv("PATH").orEmpty() + .split(File.pathSeparator) + .map { File(it, if (System.getProperty("os.name").startsWith("Windows")) "adb.exe" else "adb") } + .firstOrNull { it.isFile } + ?.absolutePath + }.getOrNull() +} 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 4b485f4..0eb8414 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt @@ -322,10 +322,18 @@ class AppSettings(private val storage: SettingsStorage = SettingsStorage.memory( var favourites: List get() = values[KEY_FAVOURITES].orEmpty().lines().mapNotNull { line -> val parts = line.split('\t') - if (parts.size < 3) null else Favorite(parts[0], parts[1], parts[2], parts.getOrElse(3) { "" }) + if (parts.size < 3) null else Favorite( + device = parts[0], + backendId = parts[1], + path = parts[2], + label = parts.getOrElse(3) { "" }, + protocol = parts.getOrElse(4) { if (parts[1] == EndpointId.LOCAL) "本端" else "CubSync" }, + ) } set(list) { - values[KEY_FAVOURITES] = list.joinToString("\n") { "${it.device}\t${it.backendId}\t${it.path}\t${it.label}" } + values[KEY_FAVOURITES] = list.joinToString("\n") { + "${it.device}\t${it.backendId}\t${it.path}\t${it.label}\t${it.protocol}" + } persist() } @@ -354,12 +362,23 @@ class AppSettings(private val storage: SettingsStorage = SettingsStorage.memory( fun toggleFavourite(favorite: Favorite) { val current = favourites - val existing = current.firstOrNull { it.device == favorite.device && it.path == favorite.path } + val existing = current.firstOrNull { it.backendId == favorite.backendId && it.path == favorite.path } favourites = if (existing != null) current - existing else current + favorite } - fun isFavourite(device: String, path: String): Boolean = - favourites.any { it.device == device && it.path == path } + /** + * Whether a folder is starred. + * + * Keyed on the backend and the path rather than on the display name: the name of a + * peer can change, and 本端 is a label rather than an address — the same folder must + * keep its star either way. + */ + fun isFavourite(backendId: String, path: String): Boolean = + favourites.any { it.backendId == backendId && it.path == path } + + fun removeFavourite(favorite: Favorite) { + favourites = favourites - favorite + } // ------------------------------------------------------------ scope layering @@ -406,12 +425,14 @@ class AppSettings(private val storage: SettingsStorage = SettingsStorage.memory( companion object { /** - * The desktop's accent, which is the prototype's `--win-accent` verbatim. + * The accent both ends start on: the brand's indigo. * - * The phone does not use this: its accent comes from [phoneAccent], because the - * prototype gives the phone its own token (`--m3-primary`) and a hue ramp on top. + * This used to be the prototype's `--win-accent` (`#1E88E5`), while the first chip + * in 取色 was `#3A5CCC` and the phone's was `#4C6EF5`. Pressing 回到品牌靛蓝 then + * produced a colour that appeared nowhere else on the screen. One value now, and + * it is the mark's. */ - const val ACCENT_DEFAULT: String = "#1E88E5" + const val ACCENT_DEFAULT: String = Brand.PRIMARY /** How many peers are remembered. Beyond this the list stops being a shortcut. */ const val KNOWN_PEER_LIMIT: Int = 12 diff --git a/service/src/main/kotlin/io/github/cubsync/service/Browser.kt b/service/src/main/kotlin/io/github/cubsync/service/Browser.kt index 8b6c228..2140691 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/Browser.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/Browser.kt @@ -109,7 +109,15 @@ object Browsing { * ones that survive a narrow pane; the fold is a crumb of its own so it can be * clicked to show the rest. */ - fun crumbs(path: String, maxSegments: Int = 5): List { + /** + * Breadcrumbs for a path, every layer of it. + * + * The default used to be five segments, so a phone header folded `/sdcard/DCIM/Camera` + * into `/ › ⋯ › Camera` even when there was room for all four and every layer is + * tappable in the design. Nothing folds now unless a caller asks for a limit; a header + * that runs out of width scrolls, which loses nothing. + */ + fun crumbs(path: String, maxSegments: Int = Int.MAX_VALUE): List { val normalized = PathTools.normalizeRoot(path) val absolute = normalized.startsWith("/") val parts = normalized.split('/').filter { it.isNotEmpty() } 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 ab14e01..44df2c9 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt @@ -145,6 +145,13 @@ class BrowserSession( // An empty path lists the exposed roots, which is the answer to "where // can this pane go back to" and is worth remembering. if (path.isBlank()) rootsCache[backendId] = entries.map { it.relPath } + // Prime the boundary for a pane opened straight onto a folder, so ← knows + // where the exposed root is before it has been listed as a listing. + if (rootsCache[backendId].isNullOrEmpty()) { + runCatching { browser.list(backendId, "").map { it.relPath } } + .getOrNull() + ?.let { rootsCache[backendId] = it } + } if (!isCurrent(side, generation)) return@launch set(side, current(side).copy(entries = sort(side, entries), loading = false)) } catch (failure: Throwable) { @@ -162,9 +169,35 @@ class BrowserSession( fun enter(side: Side, name: String) = open(side, current(side).backendId, Browsing.child(current(side).path, name)) + /** + * One level up — but not out of the folder the backend actually exposes. + * + * A phone without root can read `/storage/emulated/0`, and its pane started there. ← + * used to keep going: `/storage/emulated`, `/storage`, `/` — three levels of a + * filesystem the process cannot read, each one an error screen. The exposed root is + * the boundary; above it there is nothing to show. + */ fun up(side: Side) { - val parent = Browsing.parent(current(side).path) - if (parent != current(side).path) open(side, current(side).backendId, parent) + val state = current(side) + val parent = Browsing.parent(state.path) + if (parent == state.path) return + // A phone without root can read `/storage/emulated/0`, and its pane started there. + // ← used to keep going: `/storage/emulated`, `/storage`, `/` — three levels of a + // filesystem the process cannot read, each one an error screen. The exposed root is + // the boundary. The roots are cached by the listing that got us here, and the check + // is synchronous so that one press moves one level, as it says. + val root = synchronized(rootsCache) { + rootsCache[state.backendId].orEmpty() + .filter { state.path == it || state.path.startsWith("$it/") } + .maxByOrNull { it.length } + } + if (root != null) { + if (state.path == root) return + val inside = parent == root || parent.startsWith("$root/") + open(side, state.backendId, if (inside) parent else root) + return + } + open(side, state.backendId, parent) } /** @@ -223,6 +256,24 @@ class BrowserSession( open(side, backendId, cleaned) return@launch } + if (backendId != EndpointId.LOCAL) { + // A pane on a peer holds a path the peer exposed, not a path on this + // machine. Pasting the other machine's absolute path is the obvious thing + // to try, so an unshared one is answered with what is shared instead of a + // listing error from the other side. + val roots = rootsOf(backendId) + if (roots.isNotEmpty() && roots.none { cleaned == it || cleaned.startsWith("$it/") }) { + set( + side, + current(side).copy( + error = "远端只共享了这些目录:${roots.joinToString("、")}", + ), + ) + return@launch + } + open(side, backendId, cleaned) + return@launch + } val asFolder = browser.isFolder(backendId, cleaned) val target = Browsing.resolveTypedPath(cleaned) { asFolder } open(side, backendId, target) 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 660f8d1..b4fc12a 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt @@ -15,7 +15,18 @@ data class Favorite( val backendId: String, val path: String, val label: String = PathTools.leafName(path).ifEmpty { path }, -) + /** + * Which protocol answers for this end: 本端 for a local disk, CubSync for a peer, and + * the name of whatever comes next (WebDAV, FTP, SFTP) once those backends exist. + * + * A favourite is a place on a machine rather than a place on a screen, and on a machine + * reachable two ways it is the protocol that says which one. + */ + val protocol: String = if (backendId == EndpointId.LOCAL) "本端" else "CubSync", +) { + /** The heading this favourite belongs under. */ + val group: String get() = if (backendId == EndpointId.LOCAL) Words.LOCAL else "$device · $protocol" +} data class FavoriteGroup(val device: String, val items: List) diff --git a/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt b/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt index 83db9f3..58795a8 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt @@ -204,9 +204,14 @@ class SyncService( * lists remembered things rather than current ones is a drawer people stop trusting. */ fun drawer(server: ServerState): DrawerModel { + // Grouped by end and protocol — 本端, then one heading per peer — rather than by + // device name alone: the same machine can be reached two ways, and a list that + // mixes a folder on this disk with a folder on a phone is a list nobody can scan. val favourites = settings.favourites - .groupBy { it.device } - .map { (device, items) -> FavoriteGroup(device, items.sortedBy { it.path }) } + .groupBy { it.group } + .toList() + .sortedWith(compareBy({ if (it.first == Words.LOCAL) 0 else 1 }, { it.first })) + .map { (heading, items) -> FavoriteGroup(heading, items.sortedBy { it.path }) } val accounts = buildList { add( diff --git a/service/src/main/kotlin/io/github/cubsync/service/UiSpec.kt b/service/src/main/kotlin/io/github/cubsync/service/UiSpec.kt index 9074965..06bf54b 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/UiSpec.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/UiSpec.kt @@ -54,11 +54,19 @@ fun sectionHint(section: Section): String = section.hint * file. */ object Words { - const val LOCAL = "本机" + /** + * 本端 / 远端 rather than 本机 / 远端. + * + * The two panes are two ends of a link, and neither end has to be this computer: a + * phone talking to a phone, a disk copied to another disk, a WebDAV or SFTP mount on + * the far side. Calling the near end 本机 stopped being true the moment the far end + * could be anything, and it reads as a lie on a phone. + */ + const val LOCAL = "本端" const val REMOTE = "远端" const val SERVER = "服务端" const val CLIENT = "客户端" - const val LOCAL_PANE = "本机栏" + const val LOCAL_PANE = "本端栏" const val REMOTE_PANE = "远端栏" const val TRASH = "回收站" const val FILTER = "过滤" @@ -252,8 +260,18 @@ object Accent { ) } + /** + * The first chip is the brand's own indigo and is called 默认 on both ends. + * + * It used to be a guess at `--m3-primary` (`#4C6EF5`) while the desktop's list began + * with `#3A5CCC` and its reset button set a third colour (`#1E88E5`) — three answers + * to one question, and the screen offered two of them side by side. One value now, + * taken from the mark rather than from a stylesheet. + */ + val BRAND: String = Brand.PRIMARY + val SWATCHES: List = listOf( - Swatch("靛蓝", 245, "#4C6EF5"), + Swatch("默认", 232, BRAND), Swatch("紫", 275, hsl(275)), Swatch("青", 200, hsl(200)), Swatch("绿", 150, hsl(150)), diff --git a/service/src/test/kotlin/io/github/cubsync/service/AppSettingsTest.kt b/service/src/test/kotlin/io/github/cubsync/service/AppSettingsTest.kt index b88f408..beba9d6 100644 --- a/service/src/test/kotlin/io/github/cubsync/service/AppSettingsTest.kt +++ b/service/src/test/kotlin/io/github/cubsync/service/AppSettingsTest.kt @@ -121,14 +121,16 @@ class AppSettingsTest { val s = settings() val favourite = Favorite(device = Words.LOCAL, backendId = EndpointId.LOCAL, path = "D:/photos") s.toggleFavourite(favourite) - assertTrue(s.isFavourite(Words.LOCAL, "D:/photos")) + // Keyed on the backend and the path: the display name of an end can change, and + // "本端" is a label rather than an address. + assertTrue(s.isFavourite(EndpointId.LOCAL, "D:/photos")) assertEquals(1, s.favourites.size) s.toggleFavourite(favourite) - assertFalse(s.isFavourite(Words.LOCAL, "D:/photos")) + assertFalse(s.isFavourite(EndpointId.LOCAL, "D:/photos")) s.favourites = listOf( - favorite("本机", "D:/photos"), + favorite("本端", "D:/photos"), favorite("Redmi", "/sdcard/DCIM", backendId = "lan:192.168.1.20:43917"), ) val reloaded = AppSettings(SettingsStorage.memory(s.snapshot()))