Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 104 additions & 5 deletions app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Section>()
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
Expand All @@ -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, "")
}
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -505,6 +521,34 @@ class AndroidAppState(
touch()
}

/** The drawer row whose menu is open. */
var favouriteMenu by mutableStateOf<Favorite?>(null)

/** The pane whose 展开 list is open. */
var historySide by mutableStateOf<Side?>(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++
Expand Down Expand Up @@ -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 ?: "连接失败"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = "",
Expand All @@ -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
Expand All @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Loading
Loading