diff --git a/client/ui/frontend/src/components/ClipboardPreview.tsx b/client/ui/frontend/src/components/ClipboardPreview.tsx new file mode 100644 index 000000000..626a5f4e1 --- /dev/null +++ b/client/ui/frontend/src/components/ClipboardPreview.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { formatBytes } from "@/lib/formatters"; + +// Enough to recognise which snippet is on the clipboard without turning the +// preview into a text editor; the box clamps to three lines on top of this. +const PREVIEW_CHARS = 280; + +export const ClipboardPreview = ({ text, className }: { text: string; className?: string }) => { + const { t } = useTranslation(); + const clipped = text.length > PREVIEW_CHARS; + const bytes = new TextEncoder().encode(text).length; + + return ( +
+

+ {clipped ? `${text.slice(0, PREVIEW_CHARS)}…` : text} +

+ + {formatBytes(bytes)} + +
+ ); +}; diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx index 6b151c17b..c1a5a4d9f 100644 --- a/client/ui/frontend/src/components/buttons/Button.tsx +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -91,6 +91,11 @@ const buttonVariants = cva( danger: [ "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", ], + yellow: [ + "enabled:bg-yellow-300 enabled:text-yellow-900 enabled:hover:bg-yellow-200 enabled:focus:ring-yellow-300/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-yellow-300/40", + "enabled:dark:bg-yellow-300 enabled:dark:text-yellow-900 enabled:dark:hover:bg-yellow-200 disabled:dark:bg-nb-gray-900", + ], }, size: { xs: "px-3.5 py-2.5 text-xs", diff --git a/client/ui/frontend/src/globals.css b/client/ui/frontend/src/globals.css index 84ddfdcfe..e07da9de2 100644 --- a/client/ui/frontend/src/globals.css +++ b/client/ui/frontend/src/globals.css @@ -43,3 +43,16 @@ body { .wails-no-draggable { --wails-draggable: no-drag; } + +/* + * Radix wraps ScrollArea.Viewport children in a div with + * `min-width:100%; display:table`. A table box is shrink-to-fit, so it grows + * to the widest row's intrinsic width instead of stopping at 100%, which + * defeats `truncate` on a long file name and pushes the row out sideways. + * The window is a fixed width and cannot be resized, so the content box is + * capped here and the rows truncate as intended. + */ +.nb-scroll-clamp [data-radix-scroll-area-viewport] > div { + display: block !important; + max-width: 100%; +} diff --git a/client/ui/frontend/src/modules/main/advanced/files/Files.tsx b/client/ui/frontend/src/modules/main/advanced/files/Files.tsx index 295a7691e..9e9e68d79 100644 --- a/client/ui/frontend/src/modules/main/advanced/files/Files.tsx +++ b/client/ui/frontend/src/modules/main/advanced/files/Files.tsx @@ -7,16 +7,18 @@ import { ArrowUpIcon, BanIcon, CheckIcon, + ChevronDownIcon, CopyIcon, FolderDownIcon, FolderOpenIcon, MoreVerticalIcon, + SendIcon, ShieldCheckIcon, Trash2Icon, XIcon, } from "lucide-react"; import { FileDrop } from "@bindings/services"; -import type { FileDropTransfer } from "@bindings/services/models.js"; +import type { FileDropTransfer, PeerStatus } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { formatBytes } from "@/lib/formatters"; import { @@ -31,6 +33,9 @@ import { EmptyState } from "@/components/empty-state/EmptyState"; import { NoResults } from "@/components/empty-state/NoResults"; import { Button } from "@/components/buttons/Button"; import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { useConfirm } from "@/contexts/DialogContext"; +import { SendPeerPicker } from "@/modules/main/advanced/files/SendPeerPicker"; import { DropdownMenu, DropdownMenuContent, @@ -133,15 +138,7 @@ export const Files = () => { }); }, [rest, t]); - if (transfers !== null && transfers.length === 0) { - return ( - - ); - } + const isEmpty = transfers !== null && transfers.length === 0; return (
@@ -154,11 +151,21 @@ export const Files = () => { onChange={(e) => setSearch(e.target.value)} />
+ - {filtered.length === 0 ? ( + {isEmpty ? ( + + ) : filtered.length === 0 ? ( ) : ( - +
{pending.map((tr) => ( @@ -200,6 +207,64 @@ export const Files = () => { ); }; +const SendActions = ({ onSent }: { onSent: () => void }) => { + const { t } = useTranslation(); + const [error, setError] = useState(null); + + const sendFiles = async (peer: PeerStatus) => { + setError(null); + try { + const paths = await FileDrop.PickFiles(); + if (!paths || paths.length === 0) return; + await FileDrop.Send(peer.pubKey, paths, ""); + onSent(); + } catch (e) { + setError(String(e)); + } + }; + + // Sends the text the picker previewed, not a fresh read: the clipboard may + // have changed between showing it and confirming the recipient. + const sendClipboard = async (peer: PeerStatus, text: string) => { + setError(null); + if (!text) { + setError(t("peers.details.sendClipboard.empty")); + return; + } + try { + await FileDrop.Send(peer.pubKey, [], text); + onSent(); + } catch (e) { + setError(String(e)); + } + }; + + return ( +
+ + void (mode === "files" ? sendFiles(peer) : sendClipboard(peer, clipboard)) + } + > + + + {error && ( +
+ {error} +
+ )} +
+ ); +}; + type RowProps = { transfer: FileDropTransfer; onChanged: () => void; @@ -232,15 +297,18 @@ const PendingOfferRow = ({ transfer, onChanged }: RowProps) => { )} >
- +
- - {transferTitle(transfer)} - + + + {" · "} {formatBytes(transfer.totalSize)} @@ -286,14 +354,9 @@ const TransferRow = ({ transfer, onChanged }: RowProps) => { ? Math.min(100, Math.floor((transfer.transferred / transfer.totalSize) * 100)) : null; - // The subtitle carries who and how big; the outcome sits on the right next - // to the time, so a glance down that column reads the results. - const subtitleParts = [ - transfer.outgoing - ? t("files.row.to", { peer: transfer.peerName }) - : t("files.row.from", { peer: transfer.peerName }), - ]; - if (!isText) subtitleParts.push(formatBytes(transfer.totalSize)); + const peerLabel = transfer.outgoing + ? t("files.row.to", { peer: transfer.peerName }) + : t("files.row.from", { peer: transfer.peerName }); let stateLabel: string; if (progress !== null) { @@ -335,39 +398,47 @@ const TransferRow = ({ transfer, onChanged }: RowProps) => { aria-hidden={"true"} /> )} -
- + - {transferTitle(transfer)} - - - {subtitleParts.join(" · ")} + /> + + + {time} + {!isText && ( + + {" · "} + {formatBytes(transfer.totalSize)} + + )}
- {/* Time over outcome, capped: the window is a fixed 900px and some - translations of the state labels are long enough to eat the file - name if they share one line with the timestamp. */} + {/* Own column, capped: the window is a fixed 900px and some + translations of the state labels are long enough to eat the + file name if they are given free rein. */} - {time} - {stateLabel} + {stateLabel} {/* The window is a fixed 900px wide and cannot be resized, so the - row actions overlay the outcome column on hover instead of + row actions overlay the status column on hover instead of reserving width that the file name would otherwise lose. */}
{ const { t } = useTranslation(); + const confirm = useConfirm(); const setRule = async (rule: PeerRule) => { await FileDrop.SetPeerRule(transfer.peerKey, rule); onChanged(); }; + const remove = async () => { + const ok = await confirm({ + title: t("files.delete.title"), + description: t("files.delete.message"), + confirmLabel: t("common.delete"), + danger: true, + }); + if (!ok) return; + await FileDrop.Delete(transfer.id); + onChanged(); + }; + return ( - + // modal={false}: the item handler opens a confirm dialog, and a modal + // dropdown would keep the focus trap that the dialog then fights over. + + {mode === "clipboard" && } +
+
+ + +
+ + + + +
{t("files.send.empty")}
+
+ {peers.length === 0 && ( +
{t("files.send.noPeers")}
+ )} + {peers.map((peer) => ( + onSelect(peer)} + className={itemClass} + > + + + {shortenDns(peer.fqdn) || peer.ip} + + + {t(peerStatusLabelKey(peer.connStatus))} + + + ))} +
+
+ + + +
+ + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index 71ccbf278..0605206c4 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -38,6 +38,7 @@ import { import { FileDrop } from "@bindings/services"; import type { PeerStatus } from "@bindings/services/models.js"; import { Button } from "@/components/buttons/Button"; +import { ClipboardPreview } from "@/components/ClipboardPreview"; import { useNavSection } from "@/contexts/NavSectionContext"; import { cn } from "@/lib/cn"; import { CopyToClipboard } from "@/components/CopyToClipboard"; @@ -255,7 +256,10 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
- + {/* Keyed on the peer so a staged clipboard + confirmation cannot carry over to a different + recipient when the selection changes. */} + { const { setSection } = useNavSection(); const { setSelected } = usePeerDetail(); const [error, setError] = useState(null); + const [pendingText, setPendingText] = useState(null); // Deliberately not gated on connStatus: an idle peer is the normal resting // state under lazy connections, and the outgoing packets of the transfer are // exactly what wakes it. Only a peer without an overlay address has nothing @@ -306,7 +311,9 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => { } }; - const sendClipboard = async () => { + // Clipboard text is shown for confirmation first: sending it blind gave no + // way to tell what was about to leave the machine. + const stageClipboard = async () => { setError(null); try { const text = await FileDrop.ClipboardText(); @@ -314,7 +321,20 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => { setError(t("peers.details.sendClipboard.empty")); return; } - await FileDrop.Send(peer.pubKey, [], text); + setPendingText(text); + } catch (e) { + setError(String(e)); + } + }; + + // Sends the staged text rather than re-reading, so what was confirmed is + // what goes out. + const confirmClipboard = async () => { + if (pendingText === null) return; + setError(null); + try { + await FileDrop.Send(peer.pubKey, [], pendingText); + setPendingText(null); finishSend(); } catch (e) { setError(String(e)); @@ -323,26 +343,52 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => { return (
-
- - -
+ {pendingText === null ? ( +
+ + +
+ ) : ( +
+ +
+ + +
+
+ )} {error &&
{error}
}
); diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index 4ae61b8b4..d20e909f3 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -31,7 +31,7 @@ import { PeerFilters, type StatusFilter } from "./PeerFilters"; const isOnline = (connStatus: string) => connStatus === "Connected"; -const dotClass = (connStatus: string): string => { +export const dotClass = (connStatus: string): string => { switch (connStatus) { case "Connected": return "bg-green-400"; diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 91317185f..aef295f1b 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "Löschen" }, + "files.delete.title": { + "message": "Aus dem Verlauf entfernen?" + }, + "files.delete.message": { + "message": "Dieser Eintrag wird aus dem Übertragungsverlauf entfernt.\nBereits empfangene Dateien bleiben auf der Festplatte." + }, + "files.send.search": { + "message": "Peers suchen" + }, + "files.send.empty": { + "message": "Kein passender Peer" + }, + "files.send.noPeers": { + "message": "Keine Peers verfügbar" + }, + "files.send.trigger": { + "message": "Senden…" + }, + "files.send.back": { + "message": "Zurück" + }, + "files.send.clipboardPreview": { + "message": "Vorschau der Zwischenablage" + }, "peers.dropToSend": { "message": "Zum Senden ablegen" }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 710654320..beb61da63 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1943,6 +1943,38 @@ "message": "Delete", "description": "Remove the entry from the history." }, + "files.delete.title": { + "message": "Remove from history?", + "description": "Confirmation-dialog title for removing a transfer from the file history." + }, + "files.delete.message": { + "message": "This entry is removed from the transfer history.\nAlready received files are kept on disk.", + "description": "Confirmation body for removing a history entry; states that received files stay on disk. Contains a line break (\\n) — keep it." + }, + "files.send.search": { + "message": "Search peers", + "description": "Placeholder of the peer-picker search field." + }, + "files.send.empty": { + "message": "No matching peer", + "description": "Shown when the peer-picker search matches nothing." + }, + "files.send.noPeers": { + "message": "No peers available", + "description": "Shown in the peer picker when the peer list is empty." + }, + "files.send.trigger": { + "message": "Send…", + "description": "Label of the single send button in the Files header. Keep short." + }, + "files.send.back": { + "message": "Back", + "description": "Returns from the what-to-send step to the peer list. Keep short." + }, + "files.send.clipboardPreview": { + "message": "Clipboard preview", + "description": "Accessible label of the clipboard-text preview shown before sending." + }, "peers.dropToSend": { "message": "Drop to send", "description": "Overlay shown while dragging files over a peer row." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 28a4b2d35..b1cff6f17 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "Eliminar" }, + "files.delete.title": { + "message": "¿Quitar del historial?" + }, + "files.delete.message": { + "message": "Esta entrada se quitará del historial de transferencias.\nLos archivos ya recibidos se conservan en el disco." + }, + "files.send.search": { + "message": "Buscar peers" + }, + "files.send.empty": { + "message": "Ningún peer coincide" + }, + "files.send.noPeers": { + "message": "No hay peers disponibles" + }, + "files.send.trigger": { + "message": "Enviar…" + }, + "files.send.back": { + "message": "Atrás" + }, + "files.send.clipboardPreview": { + "message": "Vista previa del portapapeles" + }, "peers.dropToSend": { "message": "Suelta para enviar" }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index ace8b1269..24ef53356 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "Supprimer" }, + "files.delete.title": { + "message": "Retirer de l’historique ?" + }, + "files.delete.message": { + "message": "Cette entrée est retirée de l’historique des transferts.\nLes fichiers déjà reçus sont conservés sur le disque." + }, + "files.send.search": { + "message": "Rechercher des pairs" + }, + "files.send.empty": { + "message": "Aucun pair correspondant" + }, + "files.send.noPeers": { + "message": "Aucun pair disponible" + }, + "files.send.trigger": { + "message": "Envoyer…" + }, + "files.send.back": { + "message": "Retour" + }, + "files.send.clipboardPreview": { + "message": "Aperçu du presse-papiers" + }, "peers.dropToSend": { "message": "Déposer pour envoyer" }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 8a886182b..816252103 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "Törlés" }, + "files.delete.title": { + "message": "Eltávolítás az előzményekből?" + }, + "files.delete.message": { + "message": "A bejegyzés törlődik az átviteli előzményekből.\nA már fogadott fájlok a lemezen maradnak." + }, + "files.send.search": { + "message": "Gépek keresése" + }, + "files.send.empty": { + "message": "Nincs találat" + }, + "files.send.noPeers": { + "message": "Nincs elérhető gép" + }, + "files.send.trigger": { + "message": "Küldés…" + }, + "files.send.back": { + "message": "Vissza" + }, + "files.send.clipboardPreview": { + "message": "Vágólap előnézete" + }, "peers.dropToSend": { "message": "Engedd el a küldéshez" }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 3c95e3afa..74412a093 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "Elimina" }, + "files.delete.title": { + "message": "Rimuovere dalla cronologia?" + }, + "files.delete.message": { + "message": "Questa voce viene rimossa dalla cronologia dei trasferimenti.\nI file già ricevuti vengono conservati su disco." + }, + "files.send.search": { + "message": "Cerca peer" + }, + "files.send.empty": { + "message": "Nessun peer corrispondente" + }, + "files.send.noPeers": { + "message": "Nessun peer disponibile" + }, + "files.send.trigger": { + "message": "Invia…" + }, + "files.send.back": { + "message": "Indietro" + }, + "files.send.clipboardPreview": { + "message": "Anteprima degli appunti" + }, "peers.dropToSend": { "message": "Rilascia per inviare" }, diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index c05724a15..8d31daf03 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "削除" }, + "files.delete.title": { + "message": "履歴から削除しますか?" + }, + "files.delete.message": { + "message": "この項目は転送履歴から削除されます。\n受信済みのファイルはディスクに残ります。" + }, + "files.send.search": { + "message": "ピアを検索" + }, + "files.send.empty": { + "message": "一致するピアがありません" + }, + "files.send.noPeers": { + "message": "利用可能なピアがありません" + }, + "files.send.trigger": { + "message": "送信…" + }, + "files.send.back": { + "message": "戻る" + }, + "files.send.clipboardPreview": { + "message": "クリップボードのプレビュー" + }, "peers.dropToSend": { "message": "ドロップして送信" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index d3ef2166a..01cacd084 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "Excluir" }, + "files.delete.title": { + "message": "Remover do histórico?" + }, + "files.delete.message": { + "message": "Esta entrada é removida do histórico de transferências.\nOs ficheiros já recebidos são mantidos no disco." + }, + "files.send.search": { + "message": "Pesquisar peers" + }, + "files.send.empty": { + "message": "Nenhum peer correspondente" + }, + "files.send.noPeers": { + "message": "Nenhum peer disponível" + }, + "files.send.trigger": { + "message": "Enviar…" + }, + "files.send.back": { + "message": "Voltar" + }, + "files.send.clipboardPreview": { + "message": "Pré-visualização da área de transferência" + }, "peers.dropToSend": { "message": "Solte para enviar" }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index dd85936dd..99069e7a9 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "Удалить" }, + "files.delete.title": { + "message": "Удалить из истории?" + }, + "files.delete.message": { + "message": "Запись будет удалена из истории передач.\nУже полученные файлы останутся на диске." + }, + "files.send.search": { + "message": "Поиск узлов" + }, + "files.send.empty": { + "message": "Нет подходящих узлов" + }, + "files.send.noPeers": { + "message": "Нет доступных узлов" + }, + "files.send.trigger": { + "message": "Отправить…" + }, + "files.send.back": { + "message": "Назад" + }, + "files.send.clipboardPreview": { + "message": "Предпросмотр буфера обмена" + }, "peers.dropToSend": { "message": "Отпустите для отправки" }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index f7528f48c..17adbc366 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1435,6 +1435,30 @@ "files.action.delete": { "message": "删除" }, + "files.delete.title": { + "message": "从历史记录中移除?" + }, + "files.delete.message": { + "message": "此条目将从传输历史记录中移除。\n已接收的文件会保留在磁盘上。" + }, + "files.send.search": { + "message": "搜索对等节点" + }, + "files.send.empty": { + "message": "没有匹配的对等节点" + }, + "files.send.noPeers": { + "message": "没有可用的对等节点" + }, + "files.send.trigger": { + "message": "发送…" + }, + "files.send.back": { + "message": "返回" + }, + "files.send.clipboardPreview": { + "message": "剪贴板预览" + }, "peers.dropToSend": { "message": "放开即发送" },