feat: add peer list with click-to-copy (#6)

- Parse Peers from tailscale status --json into a model
- Add ListView with hostname/IP rows below status row
- Click hostname or IP to copy via wl-copy + toast confirmation
- Increase popout height to 400 to accommodate peer list
- Online peers shown in primary color, offline in surfaceVariantText
This commit is contained in:
Vybe (Coding Agent) 2026-05-17 21:59:59 +00:00
parent f980f4da21
commit 0d3f500a2c
2 changed files with 88 additions and 2 deletions

View file

@ -73,3 +73,13 @@ If this project is used in a completely local-only or air-gapped context, refer
- For self-hosted instances, include `--host` in commands unless the git remote already points at the correct instance.
- `interrogate` and `to-prd` should read this file to know which CLI and host conventions to follow.
- When in doubt about the current instance, check `git remote -v` first.
## Troubleshooting `fj` Failures
If a `fj` command fails on this or any self-hosted Forgejo instance, do not retry with different flags. Diagnose first:
1. `git remote -v` — note the URL form. URL-style remotes with explicit non-default ports (`ssh://git@host:NNNN/...`) cause `fj` to use that same port for HTTPS API calls, producing TLS errors. SCP-style remotes (`git@host:owner/repo.git`) avoid this; route non-default SSH ports via `~/.ssh/config`.
2. `cat ~/.local/share/forgejo-cli/keys.json` — the host key string is what `fj` uses for both auth lookup and API URL. A `:NNNN` suffix here is the most common root cause.
3. `fj whoami` (in-repo) and `fj -H https://<host> whoami` — comparing these tells you whether auth is stored under the bare host or under host-with-port.
`fj`'s syntax also has sharp edges worth noting: `fj issue comment <N> [BODY]` takes the body as a positional argument (not `--body`); there is no `fj issue list` — use `fj issue search "" -s open|closed|all`; and cross-repo issue references use `owner/repo#N`. When in doubt, run `<subcommand> --help``fj` is `clap`-based and its help output is reliable.

View file

@ -12,10 +12,11 @@ PluginComponent {
property bool isConnected: false
property string tailscaleIP: ""
property string currentExitNode: ""
property var peers: []
layerNamespacePlugin: "tailscalectl"
popoutWidth: 360
popoutHeight: 200
popoutHeight: 400
Timer {
interval: 5000
@ -46,10 +47,19 @@ PluginComponent {
root.isConnected = data.BackendState === "Running"
root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || ""
root.currentExitNode = data.CurrentExitNode?.HostName || ""
root.peers = Object.keys(data.Peers || {}).map(function(key) {
var p = data.Peers[key]
return {
hostname: p.HostName || key,
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
online: p.Online || false
}
})
} catch (e) {
root.isConnected = false
root.tailscaleIP = ""
root.currentExitNode = ""
root.peers = []
ToastService.showError("tailscalectl", "Failed to parse Tailscale status")
}
}
@ -60,6 +70,7 @@ PluginComponent {
root.isConnected = false
root.tailscaleIP = ""
root.currentExitNode = ""
root.peers = []
}
}
}
@ -73,6 +84,11 @@ PluginComponent {
toggleProcess.running = true
}
function copyToClipboard(text) {
Quickshell.execDetached(["sh", "-c", "echo -n '" + text + "' | wl-copy"])
ToastService.showInfo("tailscalectl", "Copied: " + text)
}
popoutContent: Component {
PopoutComponent {
headerText: "Tailscale"
@ -80,8 +96,9 @@ PluginComponent {
showCloseButton: true
Item {
id: contentItem
width: parent.width
height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingS + exitNodeText.implicitHeight + Theme.spacingM
height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM
Row {
id: statusRow
@ -128,6 +145,65 @@ PluginComponent {
anchors.verticalCenter: parent.verticalCenter
}
}
ListView {
id: peerList
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM
width: parent.width - Theme.spacingM * 2
height: Math.min(root.peers.length * (Theme.fontSizeSmall + Theme.spacingXS), 200)
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
model: root.peers
interactive: true
boundsBehavior: Flickable.DragAndOvershootBounds
delegate: Item {
width: peerList.width
height: Theme.fontSizeSmall + Theme.spacingXS
Row {
anchors.fill: parent
spacing: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
MouseArea {
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter
width: peerHostnameText.implicitWidth
height: peerHostnameText.implicitHeight
onClicked: {
root.copyToClipboard(modelData.hostname)
}
StyledText {
id: peerHostnameText
text: modelData.hostname
font.pixelSize: Theme.fontSizeSmall
color: modelData.online ? Theme.primary : Theme.surfaceVariantText
}
}
MouseArea {
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter
width: peerIpText.implicitWidth
height: peerIpText.implicitHeight
onClicked: {
root.copyToClipboard(modelData.ip)
}
StyledText {
id: peerIpText
text: modelData.ip
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
}
}
}
}
}