Drag and drop clients and channels in the server tree

Clients can be dragged into a channel and channels re-parented or
reordered, with an insertion line (or a row outline, for "into this
channel") showing where the drop lands.

Channel positions come from channel_order, which is the id of the
channel above rather than an index, so siblings are now ordered by
walking that chain and the neighbour links are repaired locally on
create, move and delete -- the server only announces the channel
that changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 17:42:49 +00:00
parent 1249242c89
commit 022aef633b
4 changed files with 412 additions and 34 deletions

View File

@@ -82,7 +82,32 @@ public final class ServerModel {
}
public synchronized void removeChannel(int id) {
channels.remove(id);
ChannelNode c = channels.remove(id);
if (c == null) return;
// Whatever followed it now follows its predecessor.
for (ChannelNode o : channels.values()) {
if (o.parentId == c.parentId && o.order == id) o.order = c.order;
}
}
/**
* Places a channel after {@code newOrder} (0 = first) under {@code newParentId},
* repairing the sibling links the server does not re-announce: the channel that
* used to follow it, and the one that follows it now.
*/
public synchronized void relinkChannel(int cid, int newParentId, int newOrder) {
ChannelNode c = channels.get(cid);
if (c == null) return;
for (ChannelNode o : channels.values()) {
if (o.id == cid) continue;
if (o.parentId == c.parentId && o.order == cid) o.order = c.order;
}
for (ChannelNode o : channels.values()) {
if (o.id == cid) continue;
if (o.parentId == newParentId && o.order == newOrder) o.order = cid;
}
c.parentId = newParentId;
c.order = newOrder;
}
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
@@ -185,20 +210,45 @@ public final class ServerModel {
if (ch != null) ch.clients.add(cl);
}
Comparator<ChannelNode> byOrder = Comparator.comparingInt((ChannelNode c) -> c.order)
.thenComparing(c -> c.name == null ? "" : c.name.toLowerCase());
Comparator<ClientEntry> byClient = Comparator
.comparingInt((ClientEntry c) -> -c.talkPower)
.thenComparing(c -> c.nickname == null ? "" : c.nickname.toLowerCase());
roots.sort(byOrder);
sortSiblings(roots);
for (ChannelNode c : channels.values()) {
c.children.sort(byOrder);
sortSiblings(c.children);
c.clients.sort(byClient);
}
return roots;
}
/**
* Orders one set of sibling channels in place. TS3 stores the position as
* {@code channel_order} = the id of the channel above it (0 = topmost), so the
* siblings form a linked list rather than a sortable key.
*/
private static void sortSiblings(List<ChannelNode> siblings) {
if (siblings.size() < 2) return;
Map<Integer, ChannelNode> byPredecessor = new LinkedHashMap<>();
for (ChannelNode c : siblings) byPredecessor.putIfAbsent(c.order, c);
List<ChannelNode> ordered = new ArrayList<>(siblings.size());
java.util.Set<Integer> placed = new java.util.HashSet<>();
for (int key = 0; ; ) {
ChannelNode next = byPredecessor.get(key);
if (next == null || !placed.add(next.id)) break;
ordered.add(next);
key = next.id;
}
// A broken chain (duplicate or dangling links) leaves stragglers; keep them
// rather than dropping channels from the tree.
for (ChannelNode c : siblings) {
if (!placed.contains(c.id)) ordered.add(c);
}
siblings.clear();
siblings.addAll(ordered);
}
public synchronized int clientCount() {
int n = 0;
for (ClientEntry c : clients.values()) if (!c.isQuery()) n++;

View File

@@ -428,6 +428,38 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-join").start();
}
/** Moves another client (or ourselves) into a channel. */
public void moveClient(int clientId, int channelId, String password) {
new Thread(() -> {
try {
client.clientMove(clientId, channelId,
(password == null || password.isEmpty()) ? null : password);
} catch (Exception e) {
ui.onError("Could not move client: " + rootMessage(e));
}
}, "ts3j-move-client").start();
}
/**
* Re-parents and repositions a channel.
*
* @param orderPredecessorId the channel this one should sit below among its new
* siblings, or 0 to place it first
*/
public void moveChannel(int channelId, int newParentId, int orderPredecessorId) {
new Thread(() -> {
try {
SingleCommand cmd = new SingleCommand("channelmove", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
cmd.add(new CommandSingleParameter("cpid", Integer.toString(newParentId)));
cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId)));
client.executeCommand(cmd).complete();
} catch (Exception e) {
ui.onError("Could not move channel: " + rootMessage(e));
}
}, "ts3j-move-channel").start();
}
public void sendChannelMessage(String text) {
new Thread(() -> {
try {
@@ -570,6 +602,7 @@ public final class TeamspeakConnection implements TS3Listener {
if (pid == 0) pid = safeInt(e, "pid");
int order = safeInt(e, "channel_order");
model.putChannel(cid, name, pid, order);
model.relinkChannel(cid, pid, order);
ui.onModelChanged();
}
@@ -594,8 +627,9 @@ public final class TeamspeakConnection implements TS3Listener {
public void onChannelMoved(ChannelMovedEvent e) {
ChannelNode ch = model.getChannel(safeInt(e, "cid"));
if (ch != null) {
if (e.get("cpid") != null) ch.parentId = e.getInt("cpid");
if (e.get("order") != null) ch.order = e.getInt("order");
int parent = e.get("cpid") != null ? e.getInt("cpid") : ch.parentId;
int order = e.get("order") != null ? e.getInt("order") : ch.order;
model.relinkChannel(ch.id, parent, order);
ui.onModelChanged();
}
}