diff --git a/storybook/qmlTests/tests/tst_BrowserLinkContextMenu.qml b/storybook/qmlTests/tests/tst_BrowserLinkContextMenu.qml
new file mode 100644
index 0000000000..ff8fb370ca
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_BrowserLinkContextMenu.qml
@@ -0,0 +1,96 @@
+import QtQuick
+import QtTest
+
+import StatusQ.Popups
+
+import AppLayouts.Browser.popups
+
+/**
+ * Link/image long-press menu (ADR 0005 "save link"): item sets per URL shape
+ * and signal payloads.
+ */
+Item {
+ id: root
+ width: 400
+ height: 400
+
+ Component {
+ id: menuComponent
+ BrowserLinkContextMenu {}
+ }
+
+ TestCase {
+ name: "BrowserLinkContextMenu"
+ when: windowShown
+
+ function enabledTexts(menu) {
+ const texts = []
+ for (let i = 0; i < menu.count; ++i) {
+ const item = menu.itemAt(i)
+ if (!item || item instanceof StatusMenuSeparator)
+ continue
+ if (item.enabled && item.text)
+ texts.push(item.text)
+ }
+ return texts
+ }
+
+ function test_linkOnly_showsLinkActions_noImage() {
+ const menu = createTemporaryObject(menuComponent, root, {
+ linkUrl: "https://example.com/file.zip",
+ imageUrl: ""
+ })
+ const texts = enabledTexts(menu)
+ verify(texts.indexOf(qsTr("Open in new tab")) >= 0)
+ verify(texts.indexOf(qsTr("Download link")) >= 0)
+ verify(texts.indexOf(qsTr("Download image")) < 0)
+ }
+
+ function test_imageOnly_showsImageDownload_noLinkActions() {
+ const menu = createTemporaryObject(menuComponent, root, {
+ linkUrl: "",
+ imageUrl: "https://example.com/photo.png"
+ })
+ const texts = enabledTexts(menu)
+ verify(texts.indexOf(qsTr("Download image")) >= 0)
+ verify(texts.indexOf(qsTr("Open in new tab")) < 0)
+ verify(texts.indexOf(qsTr("Download link")) < 0)
+ }
+
+ function test_linkedImage_showsBothSets() {
+ const menu = createTemporaryObject(menuComponent, root, {
+ linkUrl: "https://example.com/page",
+ imageUrl: "https://example.com/photo.png"
+ })
+ const texts = enabledTexts(menu)
+ verify(texts.indexOf(qsTr("Open in new tab")) >= 0)
+ verify(texts.indexOf(qsTr("Download link")) >= 0)
+ verify(texts.indexOf(qsTr("Download image")) >= 0)
+ }
+
+ function test_signals_carryTheMatchingUrl() {
+ const menu = createTemporaryObject(menuComponent, root, {
+ linkUrl: "https://example.com/file.zip",
+ imageUrl: "https://example.com/photo.png"
+ })
+
+ let downloaded = []
+ let opened = []
+ menu.downloadRequested.connect(u => downloaded.push(String(u)))
+ menu.openInNewTabRequested.connect(u => opened.push(String(u)))
+
+ for (let i = 0; i < menu.count; ++i) {
+ const action = menu.actionAt(i)
+ if (!action || !action.enabled)
+ continue
+ if (action.text === qsTr("Download link")
+ || action.text === qsTr("Download image")
+ || action.text === qsTr("Open in new tab"))
+ action.trigger()
+ }
+
+ compare(opened, ["https://example.com/file.zip"])
+ compare(downloaded, ["https://example.com/file.zip", "https://example.com/photo.png"])
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_DownloadPill.qml b/storybook/qmlTests/tests/tst_DownloadPill.qml
new file mode 100644
index 0000000000..ac1854c5fb
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_DownloadPill.qml
@@ -0,0 +1,376 @@
+import QtQuick
+import QtTest
+
+import AppLayouts.Browser.adapters
+import AppLayouts.Browser.controls
+import AppLayouts.Browser.panels
+
+import "../../../ui/app/AppLayouts/Browser/webview/DownloadFormatUtils.js" as DownloadFormatUtils
+
+/**
+ * Download Pill UI:
+ * Figma control matrix, status wording, fixed strip width.
+ */
+Item {
+ id: root
+ width: 360
+ height: 200
+
+ Component {
+ id: recordComponent
+
+ QtObject {
+ property string fileName: "report.pdf"
+ property url url: "https://example.com/report.pdf"
+ property int state: AbstractWebView.DownloadState.DownloadInProgress
+ property double receivedBytes: 400
+ property double totalBytes: 1000
+ property bool isPaused: false
+ property bool isTerminal: false
+ property bool missingFile: false
+ property var liveDownload: null
+
+ function pause() { isPaused = true; state = AbstractWebView.DownloadState.DownloadPaused }
+ function resume() { isPaused = false; state = AbstractWebView.DownloadState.DownloadInProgress }
+ function cancel() { isPaused = false; state = AbstractWebView.DownloadState.DownloadCancelled; isTerminal = true }
+ }
+ }
+
+ Component {
+ id: pillComponent
+ DownloadPill {}
+ }
+
+ Component {
+ id: stripComponent
+ DownloadPillStrip {
+ width: 360
+ }
+ }
+
+ TestCase {
+ name: "DownloadPill"
+ when: windowShown
+
+ function makePill(record, extras) {
+ const props = Object.assign({
+ download: record,
+ width: 227
+ }, extras || {})
+ return createTemporaryObject(pillComponent, root, props)
+ }
+
+ function test_inProgress_statusText_showsReceivedTotal() {
+ const record = createTemporaryObject(recordComponent, root, {
+ receivedBytes: 400,
+ totalBytes: 1000
+ })
+ const pill = makePill(record)
+ verify(pill.statusText.indexOf("/") >= 0)
+ }
+
+ function test_paused_statusText_showsReceivedTotal_notPausedWord() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadPaused,
+ isPaused: true,
+ receivedBytes: 400,
+ totalBytes: 1000
+ })
+ const pill = makePill(record)
+ verify(pill.statusText.indexOf("/") >= 0)
+ verify(pill.statusText.indexOf(qsTr("Paused")) < 0)
+ }
+
+ // Regression: a wrapped 32-bit byte count rendered negative sizes.
+ function test_statusText_survivesFilesOver2GiB() {
+ const record = createTemporaryObject(recordComponent, root, {
+ totalBytes: 3 * 1024 * 1024 * 1024, // 3 GiB > 2^31
+ receivedBytes: 2.5 * 1024 * 1024 * 1024
+ })
+ const pill = makePill(record)
+
+ const text = pill.statusText
+ verify(text.indexOf("/") >= 0)
+ verify(text.indexOf("-") < 0, "overflowed to negative: " + text)
+ verify(text.indexOf("GB") >= 0, "expected GB sizes, got: " + text)
+ }
+
+ // Missing File outranks the Record state in the subtitle.
+ function test_missingFile_statusText_overridesState() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadCompleted,
+ isTerminal: true,
+ missingFile: true
+ })
+ const pill = makePill(record)
+ compare(pill.statusText, qsTr("Missing file"))
+ }
+
+ function test_inProgress_pauseLeft_cancelRight_noOptions() {
+ const record = createTemporaryObject(recordComponent, root)
+ const pill = makePill(record)
+
+ compare(pill.primaryAction, DownloadPill.PrimaryAction.Pause)
+ verify(pill.pauseButtonVisible)
+ verify(!pill.resumeButtonVisible)
+ verify(pill.cancelButtonVisible)
+ verify(!pill.optionsButtonVisible)
+ }
+
+ function test_paused_resumeLeft_cancelRight_noOptions() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadPaused,
+ isPaused: true
+ })
+ const pill = makePill(record)
+
+ compare(pill.primaryAction, DownloadPill.PrimaryAction.Resume)
+ verify(pill.resumeButtonVisible)
+ verify(!pill.pauseButtonVisible)
+ verify(pill.cancelButtonVisible)
+ verify(!pill.optionsButtonVisible)
+
+ // The rendered controls follow the flags.
+ const primary = findChild(pill, "downloadPillPrimaryButton")
+ verify(!!primary)
+ verify(primary.visible)
+ compare(primary.icon.name, "play")
+
+ const cancel = findChild(pill, "downloadPillCancelButton")
+ verify(!!cancel)
+ verify(cancel.visible)
+
+ const options = findChild(pill, "downloadPillOptionsButton")
+ verify(!!options)
+ verify(!options.visible)
+ }
+
+ function test_completed_fileLeft_optionsRight_noCancel_emptyStatus() {
+ const done = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadCompleted,
+ isTerminal: true,
+ receivedBytes: 1000
+ })
+ const pill = makePill(done)
+
+ compare(pill.primaryAction, DownloadPill.PrimaryAction.File)
+ verify(pill.optionsButtonVisible)
+ verify(!pill.cancelButtonVisible)
+ compare(pill.statusText, "")
+ }
+
+ function test_cancelled_iconLeft_cancelledStatus_hasOptionsMenu() {
+ // Cancelled keeps its ⋮ so Retry/Dismiss stay reachable from the strip.
+ const cancelled = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadCancelled,
+ isTerminal: true
+ })
+ const pill = makePill(cancelled)
+
+ compare(pill.primaryAction, DownloadPill.PrimaryAction.Cancelled)
+ compare(pill.statusText, qsTr("Cancelled"))
+ verify(pill.optionsButtonVisible)
+ verify(!pill.cancelButtonVisible)
+ }
+
+ function test_interrupted_optionsRight_shortStatus_noInlineCancel() {
+ const interrupted = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadInterrupted,
+ isTerminal: true
+ })
+ const pill = makePill(interrupted)
+
+ compare(pill.primaryAction, DownloadPill.PrimaryAction.None)
+ verify(pill.optionsButtonVisible)
+ verify(!pill.cancelButtonVisible)
+ compare(pill.statusText, qsTr("Interrupted"))
+ }
+
+ function test_cancelButton_forwardsToRecord() {
+ const record = createTemporaryObject(recordComponent, root)
+ const pill = makePill(record)
+
+ pill.triggerCancel()
+ compare(record.state, AbstractWebView.DownloadState.DownloadCancelled)
+ compare(pill.primaryAction, DownloadPill.PrimaryAction.Cancelled)
+ verify(!pill.cancelButtonVisible)
+ verify(pill.optionsButtonVisible) // Cancelled keeps its ⋮
+ }
+
+ // Missing File follows the Record, not the surface — the strikeout
+ // lives in the pill, so both strip and list rows get it.
+ function test_missingFile_strikesThroughFileName() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadCompleted,
+ isTerminal: true,
+ missingFile: true
+ })
+ const pill = makePill(record)
+
+ const label = findChild(pill, "downloadPillFileNameLabel")
+ verify(!!label)
+ verify(label.font.strikeout, "Missing File strikes through the file name")
+ }
+
+ function test_elideFileName_middleElidesBase_keepsExtension() {
+ const longName = "very-long-download-report-name.pdf"
+ const elided = DownloadFormatUtils.elideFileName(longName, 18)
+
+ verify(elided.endsWith(".pdf"))
+ verify(elided.indexOf("…") >= 0 || elided.indexOf("...") >= 0)
+ verify(elided.length <= 18)
+ compare(DownloadFormatUtils.elideFileName("short.pdf", 40), "short.pdf")
+ compare(DownloadFormatUtils.elideFileName("noext", 4).length <= 4, true)
+ }
+
+ function test_strip_singlePill_keepsFixedWidth_leftAligned() {
+ const record = createTemporaryObject(recordComponent, root)
+ const strip = createTemporaryObject(stripComponent, root)
+ strip.downloadsModel = [record]
+ strip.width = 360
+ strip.height = 44
+ waitForRendering(strip)
+
+ compare(strip.pillWidth, 227)
+
+ const listView = findChild(strip, "downloadPillListView")
+ verify(!!listView)
+ compare(listView.count, 1)
+ const pill = listView.itemAtIndex(0)
+ verify(!!pill)
+ compare(pill.width, 227)
+ // Lone pill must not stretch to the strip content width.
+ verify(pill.width < listView.width)
+ }
+
+ function test_strip_multiPill_keepsFixedWidth_noShrink() {
+ const a = createTemporaryObject(recordComponent, root, { fileName: "a.pdf" })
+ const b = createTemporaryObject(recordComponent, root, { fileName: "b.pdf" })
+ const c = createTemporaryObject(recordComponent, root, { fileName: "c.pdf" })
+ const strip = createTemporaryObject(stripComponent, root)
+ strip.downloadsModel = [a, b, c]
+ strip.width = 360
+ strip.height = 44
+ waitForRendering(strip)
+
+ const listView = findChild(strip, "downloadPillListView")
+ verify(!!listView)
+ compare(listView.count, 3)
+ compare(strip.pillWidth, 227)
+
+ // Newest-first + positionViewAtBeginning → first pill is on-screen.
+ listView.forceLayout()
+ waitForRendering(strip)
+ const first = listView.itemAtIndex(0)
+ verify(!!first)
+ compare(first.width, 227)
+
+ // Three fixed 227px pills exceed the strip content area.
+ verify(listView.contentWidth > listView.width)
+ verify(listView.contentWidth >= strip.pillWidth * 3)
+ }
+
+ function test_strip_newDownload_insertsAtLeft() {
+ const older = createTemporaryObject(recordComponent, root, { fileName: "older.pdf" })
+ const newer = createTemporaryObject(recordComponent, root, { fileName: "newer.pdf" })
+ const strip = createTemporaryObject(stripComponent, root)
+ strip.width = 600
+ strip.height = 44
+ strip.downloadsModel = [older]
+ waitForRendering(strip)
+
+ strip.downloadsModel = [newer, older]
+ waitForRendering(strip)
+
+ const listView = findChild(strip, "downloadPillListView")
+ verify(!!listView)
+ compare(listView.count, 2)
+ listView.forceLayout()
+ waitForRendering(strip)
+
+ const left = listView.itemAtIndex(0)
+ verify(!!left)
+ compare(left.download, newer)
+ }
+
+ function test_strip_figmaChrome_flushPills_onlyActiveCardIsWhite() {
+ const active = createTemporaryObject(recordComponent, root, { fileName: "a.pdf" })
+ const done = createTemporaryObject(recordComponent, root, {
+ fileName: "b.pdf",
+ state: AbstractWebView.DownloadState.DownloadCompleted,
+ isTerminal: true
+ })
+ const strip = createTemporaryObject(stripComponent, root)
+ strip.downloadsModel = [active, done]
+ strip.width = 600
+ waitForRendering(strip)
+
+ compare(strip.implicitHeight, 44)
+
+ const listView = findChild(strip, "downloadPillListView")
+ verify(!!listView)
+ listView.forceLayout()
+ waitForRendering(strip)
+
+ const first = listView.itemAtIndex(0)
+ const second = listView.itemAtIndex(1)
+ verify(!!first && !!second)
+ verify(first.highlighted)
+ verify(!second.highlighted)
+ compare(first.height, strip.implicitHeight)
+
+ // Pills sit flush — the strip tint separates them, not a gap.
+ compare(second.x - first.x, strip.pillWidth)
+ compare(second.color, strip.color)
+ verify(first.color !== second.color)
+ }
+
+ // Strip signals carry the Download Record, not a strip index.
+ function test_strip_click_and_options_emitTheRecord() {
+ const done = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadCompleted,
+ isTerminal: true
+ })
+ const strip = createTemporaryObject(stripComponent, root)
+ strip.downloadsModel = [done]
+ strip.width = 360
+ strip.height = 44
+ waitForRendering(strip)
+
+ const listView = findChild(strip, "downloadPillListView")
+ verify(!!listView)
+ const pill = listView.itemAtIndex(0)
+ verify(!!pill)
+
+ let clicked = null
+ strip.openDownloadClicked.connect(function (r) { clicked = r })
+ pill.itemClicked()
+ compare(clicked, done)
+
+ let gotRecord = null
+ let gotAnchor = null
+ strip.optionsClicked.connect(function (r, anchor) {
+ gotRecord = r
+ gotAnchor = anchor
+ })
+ const options = findChild(pill, "downloadPillOptionsButton")
+ verify(!!options)
+ verify(options.visible)
+ mouseClick(options)
+ compare(gotRecord, done)
+ verify(!!gotAnchor, "anchor Item for menu alignment")
+ }
+
+ function test_primaryPause_forwardsToRecord() {
+ const record = createTemporaryObject(recordComponent, root)
+ const pill = makePill(record)
+
+ pill.triggerPrimaryAction()
+ compare(record.state, AbstractWebView.DownloadState.DownloadPaused)
+ compare(pill.primaryAction, DownloadPill.PrimaryAction.Resume)
+
+ pill.triggerPrimaryAction()
+ compare(record.state, AbstractWebView.DownloadState.DownloadInProgress)
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_DownloadRecordMenu.qml b/storybook/qmlTests/tests/tst_DownloadRecordMenu.qml
new file mode 100644
index 0000000000..7f301ce44b
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_DownloadRecordMenu.qml
@@ -0,0 +1,269 @@
+import QtQuick
+import QtTest
+
+import StatusQ.Popups
+
+import AppLayouts.Browser.adapters
+import AppLayouts.Browser.popups
+
+/**
+ * Shared DownloadRecordMenu:
+ * item sets + Share/Copy labels from one bound `capabilities` object.
+ */
+Item {
+ id: root
+ width: 400
+ height: 400
+
+ Component {
+ id: recordComponent
+
+ QtObject {
+ property string fileName: "report.pdf"
+ property url url: "https://example.com/report.pdf"
+ property string targetPath: "/tmp/downloads/report.pdf"
+ property string mimeType: "application/pdf"
+ property int state: AbstractWebView.DownloadState.DownloadCompleted
+ property double receivedBytes: 1000
+ property double totalBytes: 1000
+ property bool isPaused: false
+ property bool isTerminal: true
+ property bool missingFile: false
+ property bool isInline: false
+ property var liveDownload: null
+
+ function pause() { isPaused = true; state = AbstractWebView.DownloadState.DownloadPaused }
+ function resume() { isPaused = false; state = AbstractWebView.DownloadState.DownloadInProgress }
+ function cancel() { isPaused = false; state = AbstractWebView.DownloadState.DownloadCancelled; isTerminal = true }
+ }
+ }
+
+ Component {
+ id: menuComponent
+ DownloadRecordMenu {}
+ }
+
+ TestCase {
+ name: "DownloadRecordMenu"
+ when: windowShown
+
+ /// One capabilities object per case.
+ function caps(overrides) {
+ return Object.assign({
+ openInBrowser: false,
+ shareFile: false,
+ shareUrl: false,
+ showInFolder: false,
+ retry: false,
+ dismiss: false,
+ downloadsEntry: false,
+ useShareLabels: false
+ }, overrides || {})
+ }
+
+ function actionTexts(menu) {
+ const texts = []
+ for (let i = 0; i < menu.count; ++i) {
+ const item = menu.itemAt(i)
+ if (!item || item instanceof StatusMenuSeparator)
+ continue
+ if (item.enabled && item.text)
+ texts.push(item.text)
+ }
+ return texts
+ }
+
+ function test_completed_desktop_copyLabels_andShowInFolder() {
+ const record = createTemporaryObject(recordComponent, root)
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record,
+ capabilities: caps({
+ openInBrowser: true,
+ shareFile: true,
+ shareUrl: true,
+ showInFolder: true
+ })
+ })
+
+ const texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Copy file path")) >= 0)
+ verify(texts.indexOf(qsTr("Copy file")) < 0)
+ verify(texts.indexOf(qsTr("Copy URL")) >= 0)
+ verify(texts.indexOf(qsTr("Open in Browser")) >= 0)
+ verify(texts.indexOf(qsTr("Show in folder")) >= 0)
+ verify(texts.indexOf(qsTr("Share file")) < 0)
+ verify(texts.indexOf(qsTr("Pause")) < 0)
+ verify(texts.indexOf(qsTr("Cancel")) < 0)
+ }
+
+ function test_completed_mobile_shareLabels_hideShowInFolderOnIos() {
+ const record = createTemporaryObject(recordComponent, root)
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record,
+ capabilities: caps({
+ shareFile: true,
+ shareUrl: true,
+ useShareLabels: true
+ })
+ })
+
+ const texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Share file")) >= 0)
+ verify(texts.indexOf(qsTr("Share URL")) >= 0)
+ verify(texts.indexOf(qsTr("Show in folder")) < 0)
+ }
+
+ function test_active_list_exposesPauseResumeCancel() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadInProgress,
+ isTerminal: false,
+ receivedBytes: 100
+ })
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record,
+ capabilities: caps({ useShareLabels: true })
+ })
+
+ const texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Pause")) >= 0)
+ verify(texts.indexOf(qsTr("Cancel")) >= 0)
+ verify(texts.indexOf(qsTr("Share file")) < 0)
+
+ record.pause()
+ const paused = actionTexts(menu)
+ verify(paused.indexOf(qsTr("Resume")) >= 0)
+ verify(paused.indexOf(qsTr("Cancel")) >= 0)
+ }
+
+ function test_interrupted_retryAndUrlActions() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadInterrupted,
+ isTerminal: true
+ })
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record,
+ capabilities: caps({ shareUrl: true, retry: true })
+ })
+
+ const texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Retry")) >= 0)
+ verify(texts.indexOf(qsTr("Copy URL")) >= 0)
+ verify(texts.indexOf(qsTr("Cancel")) < 0)
+ }
+
+ function test_pill_completed_canShowDismiss() {
+ const record = createTemporaryObject(recordComponent, root)
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record,
+ capabilities: caps({
+ shareFile: true,
+ shareUrl: true,
+ showInFolder: true,
+ dismiss: true,
+ useShareLabels: true
+ })
+ })
+
+ const texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Dismiss")) >= 0)
+ }
+
+ function test_actionSignals_arePlain_recordIsTheMenus() {
+ const record = createTemporaryObject(recordComponent, root)
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record,
+ capabilities: caps({ retry: true, shareUrl: true })
+ })
+
+ let retried = 0
+ menu.retryRequested.connect(function() { retried += 1 })
+ menu.retryRequested()
+ compare(retried, 1)
+ // The caller reads the Record straight off the menu.
+ compare(menu.record, record)
+ }
+
+ /// The pill strip menu leads with "Downloads" (opens the
+ /// Downloads List section of the Open tabs overview), above a divider.
+ function test_downloadsEntry_firstInStripMenu_emitsSignal() {
+ const record = createTemporaryObject(recordComponent, root)
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record,
+ capabilities: caps({
+ shareFile: true,
+ shareUrl: true,
+ showInFolder: true,
+ dismiss: true,
+ downloadsEntry: true
+ })
+ })
+
+ const texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Downloads")) >= 0)
+ compare(texts[0], qsTr("Downloads"), "Downloads leads the strip menu")
+
+ let opened = 0
+ menu.downloadsRequested.connect(function() { opened += 1 })
+ for (let i = 0; i < menu.count; ++i) {
+ const action = menu.actionAt(i)
+ if (action && action.enabled && action.text === qsTr("Downloads"))
+ action.trigger()
+ }
+ compare(opened, 1, "activating the entry requests the Downloads List")
+ }
+
+ /// List-row menus never show the entry — the user is already
+ /// in the Downloads List.
+ function test_downloadsEntry_absentFromListMenus() {
+ const record = createTemporaryObject(recordComponent, root)
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record,
+ capabilities: caps({
+ openInBrowser: true,
+ shareFile: true,
+ shareUrl: true,
+ showInFolder: true
+ })
+ })
+
+ const texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Downloads")) < 0)
+ }
+
+ /// `capabilities` is a BINDING at the call site, so the menu
+ /// can never show stale capabilities — flipping the record's state
+ /// re-derives the object with no populate step in between.
+ function test_capabilitiesBinding_followsRecordState_noStaleMenu() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadInProgress,
+ isTerminal: false
+ })
+ const menu = createTemporaryObject(menuComponent, root, {
+ record: record
+ })
+ const self = this
+ menu.capabilities = Qt.binding(function() {
+ const complete = !!menu.record
+ && menu.record.state === AbstractWebView.DownloadState.DownloadCompleted
+ return self.caps({
+ openInBrowser: complete,
+ shareFile: complete,
+ shareUrl: true
+ })
+ })
+
+ let texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Open in Browser")) < 0)
+ verify(texts.indexOf(qsTr("Pause")) >= 0)
+
+ record.state = AbstractWebView.DownloadState.DownloadCompleted
+ record.isTerminal = true
+
+ texts = actionTexts(menu)
+ verify(texts.indexOf(qsTr("Open in Browser")) >= 0,
+ "capabilities re-derive from the record with no populate call")
+ verify(texts.indexOf(qsTr("Copy file path")) >= 0)
+ verify(texts.indexOf(qsTr("Pause")) < 0)
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_DownloadsListView.qml b/storybook/qmlTests/tests/tst_DownloadsListView.qml
new file mode 100644
index 0000000000..dae6e35d74
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_DownloadsListView.qml
@@ -0,0 +1,182 @@
+import QtQuick
+import QtTest
+
+import AppLayouts.Browser.adapters
+import AppLayouts.Browser.controls
+import AppLayouts.Browser.panels
+
+/**
+ * DownloadsListView: empty line only at zero Records.
+ * Model may arrive via object construction (BrowserLayout → createObject), where a
+ * JS array is converted and no longer reports as Array — count must use .length.
+ * Rows render through the shared DownloadPill delegate; its per-state controls
+ * matrix is asserted in tst_DownloadPill.
+ */
+Item {
+ id: root
+ width: 360
+ height: 400
+
+ Component {
+ id: recordComponent
+
+ QtObject {
+ property string fileName: "report.pdf"
+ property url url: "https://example.com/report.pdf"
+ property int state: AbstractWebView.DownloadState.DownloadCompleted
+ property bool isPaused: false
+ property bool missingFile: false
+
+ function pause() {
+ isPaused = true
+ state = AbstractWebView.DownloadState.DownloadPaused
+ }
+ function resume() {
+ isPaused = false
+ state = AbstractWebView.DownloadState.DownloadInProgress
+ }
+ function cancel() {
+ isPaused = false
+ state = AbstractWebView.DownloadState.DownloadCancelled
+ }
+ }
+ }
+
+ Component {
+ id: listViewComponent
+
+ DownloadsListView {
+ width: 360
+ height: 400
+ }
+ }
+
+ TestCase {
+ name: "DownloadsListView"
+ when: windowShown
+
+ function emptyLabel(view) {
+ return findChild(view, "downloadsListEmptyLabel")
+ }
+
+ function listRow(view, index) {
+ const list = findChild(view, "downloadsListView")
+ verify(!!list)
+ list.forceLayout()
+ waitForRendering(view)
+ const row = list.itemAtIndex(index)
+ verify(!!row, "list row " + index)
+ return row
+ }
+
+ // Rows render through the shared DownloadPill delegate.
+ function rowPill(view, index) {
+ const pill = findChild(listRow(view, index), "downloadPill")
+ verify(!!pill, "row " + index + " renders the shared DownloadPill")
+ return pill
+ }
+
+ function test_empty_placeholder_visible_when_noRecords() {
+ const view = createTemporaryObject(listViewComponent, root, {
+ downloadsModel: []
+ })
+ waitForRendering(view)
+
+ const label = emptyLabel(view)
+ verify(!!label)
+ verify(label.visible)
+ compare(label.text, qsTr("Downloaded files will appear here."))
+ }
+
+ function test_empty_placeholder_hidden_when_modelAssignedDirectly() {
+ const record = createTemporaryObject(recordComponent, root)
+ const view = createTemporaryObject(listViewComponent, root)
+ view.downloadsModel = [record]
+ waitForRendering(view)
+
+ const label = emptyLabel(view)
+ verify(!!label)
+ verify(!label.visible)
+ }
+
+ function test_empty_placeholder_hidden_when_modelViaCreateObject() {
+ // Mirrors BrowserLayout → TabsBookmarksOverviewModal createObject path.
+ const record = createTemporaryObject(recordComponent, root)
+ const view = listViewComponent.createObject(root, {
+ downloadsModel: [record],
+ width: 360,
+ height: 400
+ })
+ verify(!!view)
+ waitForRendering(view)
+
+ // The bug: Array.isArray fails after createObject conversion.
+ // Length must still report non-empty.
+ verify(view._count >= 1)
+
+ const label = emptyLabel(view)
+ verify(!!label)
+ verify(!label.visible)
+
+ view.destroy()
+ }
+
+ // The per-state controls matrix lives in the shared DownloadPill and is
+ // asserted once, in tst_DownloadPill. The list keeps one shared-delegate
+ // case (below) plus its own concerns: empty state and record signals.
+ function test_cancelled_showsOptionsMenu_inList() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadCancelled
+ })
+ const view = createTemporaryObject(listViewComponent, root, {
+ downloadsModel: [record]
+ })
+ waitForRendering(view)
+
+ const pill = rowPill(view, 0)
+ compare(pill.primaryAction, DownloadPill.PrimaryAction.Cancelled)
+ verify(!pill.cancelButtonVisible)
+ verify(pill.optionsButtonVisible, "Cancelled keeps its ⋮ (Retry/Dismiss reachable)")
+
+ const options = findChild(pill, "downloadPillOptionsButton")
+ verify(!!options)
+ verify(options.visible)
+ }
+
+ // View signals carry the Download Record, not a list index.
+ function test_rowClick_emitsTheRecord() {
+ const record = createTemporaryObject(recordComponent, root)
+ const view = createTemporaryObject(listViewComponent, root, {
+ downloadsModel: [record]
+ })
+ waitForRendering(view)
+
+ let got = null
+ view.openDownloadClicked.connect(function (r) { got = r })
+ mouseClick(listRow(view, 0))
+ compare(got, record)
+ }
+
+ function test_optionsClick_emitsRecordAndAnchor() {
+ const record = createTemporaryObject(recordComponent, root, {
+ state: AbstractWebView.DownloadState.DownloadCompleted
+ })
+ const view = createTemporaryObject(listViewComponent, root, {
+ downloadsModel: [record]
+ })
+ waitForRendering(view)
+
+ let gotRecord = null
+ let gotAnchor = null
+ view.optionsClicked.connect(function (r, anchor) {
+ gotRecord = r
+ gotAnchor = anchor
+ })
+ const options = findChild(rowPill(view, 0), "downloadPillOptionsButton")
+ verify(!!options)
+ mouseClick(options)
+ compare(gotRecord, record)
+ verify(!!gotAnchor, "anchor Item for menu alignment")
+ }
+ }
+}
diff --git a/ui/StatusQ/src/assets/img/icons/downloads-cancel.svg b/ui/StatusQ/src/assets/img/icons/downloads-cancel.svg
new file mode 100644
index 0000000000..56fe86ff9c
--- /dev/null
+++ b/ui/StatusQ/src/assets/img/icons/downloads-cancel.svg
@@ -0,0 +1,6 @@
+
diff --git a/ui/StatusQ/src/assets/img/icons/more-v.svg b/ui/StatusQ/src/assets/img/icons/more-v.svg
new file mode 100644
index 0000000000..b820d85eaa
--- /dev/null
+++ b/ui/StatusQ/src/assets/img/icons/more-v.svg
@@ -0,0 +1 @@
+
diff --git a/ui/StatusQ/src/assets/img/img.qrc b/ui/StatusQ/src/assets/img/img.qrc
index 3fd4475378..8398f1e419 100644
--- a/ui/StatusQ/src/assets/img/img.qrc
+++ b/ui/StatusQ/src/assets/img/img.qrc
@@ -136,6 +136,7 @@
icons/dots-icon.svg
icons/double-checkmark.svg
icons/download.svg
+ icons/downloads-cancel.svg
icons/downloads.svg
icons/edit.svg
icons/edit_pencil.svg
@@ -194,6 +195,7 @@
icons/mobile-sync-off.svg
icons/mobile-sync.svg
icons/mobile.svg
+ icons/more-v.svg
icons/more.svg
icons/muted.svg
icons/network.svg
diff --git a/ui/app/AppLayouts/Browser/controls/DownloadPill.qml b/ui/app/AppLayouts/Browser/controls/DownloadPill.qml
new file mode 100644
index 0000000000..03abb719a9
--- /dev/null
+++ b/ui/app/AppLayouts/Browser/controls/DownloadPill.qml
@@ -0,0 +1,306 @@
+import QtQuick
+import QtQuick.Layouts
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Controls
+
+import AppLayouts.Browser.adapters
+
+import "../webview/DownloadFormatUtils.js" as DownloadFormatUtils
+
+/**
+ * The one delegate for a Download Record (Browser CONTEXT / Figma File
+ * download): the strip renders it as a capsule, the Downloads
+ * List as a flat row. The state-to-controls matrix lives here and only here.
+ * Left: Pause | Play | file | downloads. Right: downloads-cancel | more-v.
+ */
+Rectangle {
+ id: root
+
+ enum PrimaryAction {
+ None,
+ Pause,
+ Resume,
+ File,
+ Cancelled
+ }
+
+ property var download: null
+
+ // Eliding comes from DownloadFormatUtils; the wording lives here because
+ // lupdate only scans QML.
+
+ // Presentation knobs: the surfaces differ in
+ // chrome, not in behaviour. Defaults draw the strip capsule; the Downloads
+ // List overrides them to render a flat row inside its own hover highlight.
+ property color pillColor: root.highlighted ? Theme.palette.background : Theme.palette.baseColor2
+ property int nameFontSize: Theme.additionalTextSize
+ property int statusFontSize: Theme.tertiaryTextFontSize
+ property int leadingSlotSize: 24
+ property int contentSpacing: Theme.halfPadding
+ property int leadingMargin: 12
+ property int trailingMargin: Theme.halfPadding
+ // False when a surrounding delegate (Downloads List row) owns the click.
+ property bool interactive: true
+
+ readonly property int primaryAction: {
+ if (!download)
+ return DownloadPill.PrimaryAction.None
+ const state = download.state
+ if (state === AbstractWebView.DownloadState.DownloadCompleted)
+ return DownloadPill.PrimaryAction.File
+ if (state === AbstractWebView.DownloadState.DownloadCancelled)
+ return DownloadPill.PrimaryAction.Cancelled
+ if (state === AbstractWebView.DownloadState.DownloadPaused || download.isPaused)
+ return DownloadPill.PrimaryAction.Resume
+ if (state === AbstractWebView.DownloadState.DownloadInProgress
+ || state === AbstractWebView.DownloadState.DownloadRequested)
+ return DownloadPill.PrimaryAction.Pause
+ // Interrupted: tap retries via BrowserDownloadsContext; no inline control.
+ return DownloadPill.PrimaryAction.None
+ }
+
+ readonly property bool pauseButtonVisible: primaryAction === DownloadPill.PrimaryAction.Pause
+ readonly property bool resumeButtonVisible: primaryAction === DownloadPill.PrimaryAction.Resume
+ readonly property bool cancelButtonVisible: primaryAction === DownloadPill.PrimaryAction.Pause
+ || primaryAction === DownloadPill.PrimaryAction.Resume
+ // Cancelled keeps its ⋮ so Retry/Dismiss stay reachable.
+ readonly property bool optionsButtonVisible: primaryAction === DownloadPill.PrimaryAction.File
+ || primaryAction === DownloadPill.PrimaryAction.None
+ || primaryAction === DownloadPill.PrimaryAction.Cancelled
+
+ readonly property bool missingFile: !!(download && download.missingFile)
+
+ /// Figma File download: only non-terminal Records get the white card; finished
+ /// ones blend into the strip background.
+ readonly property bool highlighted: primaryAction === DownloadPill.PrimaryAction.Pause
+ || primaryAction === DownloadPill.PrimaryAction.Resume
+
+ readonly property string fileNameText: {
+ const name = download?.fileName ?? ""
+ if (!name)
+ return ""
+ if (fileNameLabel.width <= 0)
+ return name
+ // Fit by measured width (char-budget from "x" under-elides and paints into Cancel).
+ if (fileNameMetrics.advanceWidth(name) <= fileNameLabel.width)
+ return name
+ let lo = 4
+ let hi = name.length
+ let best = DownloadFormatUtils.elideFileName(name, lo)
+ while (lo <= hi) {
+ const mid = Math.floor((lo + hi) / 2)
+ const candidate = DownloadFormatUtils.elideFileName(name, mid)
+ if (fileNameMetrics.advanceWidth(candidate) <= fileNameLabel.width) {
+ best = candidate
+ lo = mid + 1
+ } else {
+ hi = mid - 1
+ }
+ }
+ return best
+ }
+
+ /// Subtitle wording, one per state. InProgress, Requested and Paused all
+ /// show received/total — the Resume control already says "paused".
+ readonly property string statusText: {
+ if (!download)
+ return ""
+ if (root.missingFile)
+ return qsTr("Missing file")
+ const state = download.state
+ if (state === AbstractWebView.DownloadState.DownloadCompleted)
+ return ""
+ if (state === AbstractWebView.DownloadState.DownloadCancelled)
+ return qsTr("Cancelled")
+ if (state === AbstractWebView.DownloadState.DownloadInterrupted)
+ return qsTr("Interrupted")
+ if (state === AbstractWebView.DownloadState.DownloadInProgress
+ || state === AbstractWebView.DownloadState.DownloadRequested
+ || state === AbstractWebView.DownloadState.DownloadPaused
+ || download.isPaused) {
+ const sizeFormat = Locale.DataSizeTraditionalFormat
+ const received = download.receivedBytes ?? 0
+ const total = download.totalBytes ?? 0
+ if (total > 0) {
+ return "%1 / %2"
+ .arg(Qt.locale().formattedDataSize(received, 2, sizeFormat))
+ .arg(Qt.locale().formattedDataSize(total, 2, sizeFormat))
+ }
+ return Qt.locale().formattedDataSize(received, 2, sizeFormat)
+ }
+ return ""
+ }
+
+ /// anchor is the ⋮ button itself — the menu right-aligns under (or over) it.
+ signal optionsButtonClicked(Item anchor)
+ signal primaryActionTriggered()
+ signal cancelTriggered()
+ signal itemClicked()
+
+ objectName: "downloadPill"
+
+ implicitHeight: 44
+ implicitWidth: 227
+ height: implicitHeight
+ color: root.pillColor
+ clip: true
+
+ // FontMetrics.advanceWidth(text) is a method; TextMetrics.advanceWidth is a property.
+ FontMetrics {
+ id: fileNameMetrics
+ font: fileNameLabel.font
+ }
+
+ function triggerPrimaryAction() {
+ if (!download)
+ return
+ if (primaryAction === DownloadPill.PrimaryAction.Pause && download.pause)
+ download.pause()
+ else if (primaryAction === DownloadPill.PrimaryAction.Resume && download.resume)
+ download.resume()
+ primaryActionTriggered()
+ }
+
+ function triggerCancel() {
+ if (!download || !cancelButtonVisible)
+ return
+ if (download.cancel)
+ download.cancel()
+ cancelTriggered()
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: root.interactive
+ acceptedButtons: Qt.LeftButton
+ onClicked: root.itemClicked()
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ // Figma File download: 12px inset, 8px between Play/text and text/Cancel.
+ anchors.leftMargin: root.leadingMargin
+ anchors.rightMargin: root.trailingMargin
+ spacing: root.contentSpacing
+
+ // Fixed leading slot — without a shared width, filenames jog left/right by state.
+ Item {
+ Layout.preferredWidth: root.leadingSlotSize
+ Layout.preferredHeight: root.leadingSlotSize
+ Layout.alignment: Qt.AlignVCenter
+
+ StatusFlatRoundButton {
+ id: primaryBtn
+ objectName: "downloadPillPrimaryButton"
+ anchors.centerIn: parent
+ width: root.leadingSlotSize
+ height: root.leadingSlotSize
+ visible: root.pauseButtonVisible || root.resumeButtonVisible
+ icon.name: root.pauseButtonVisible ? "pause" : "play"
+ type: StatusFlatRoundButton.Type.Tertiary
+ onClicked: root.triggerPrimaryAction()
+ }
+
+ StatusIcon {
+ anchors.centerIn: parent
+ width: 24
+ height: 24
+ visible: !primaryBtn.visible
+ icon: root.primaryAction === DownloadPill.PrimaryAction.Cancelled ? "downloads" : "file"
+ color: root.missingFile
+ || root.primaryAction === DownloadPill.PrimaryAction.Cancelled
+ ? Theme.palette.baseColor1
+ : Theme.palette.directColor1
+ opacity: root.primaryAction === DownloadPill.PrimaryAction.None ? 0.5 : 1
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.minimumWidth: 0
+ Layout.preferredHeight: textColumn.implicitHeight
+ Layout.alignment: Qt.AlignVCenter
+ clip: true
+
+ ColumnLayout {
+ id: textColumn
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 0
+
+ StatusBaseText {
+ id: fileNameLabel
+ objectName: "downloadPillFileNameLabel"
+ Layout.fillWidth: true
+ text: root.fileNameText
+ elide: Text.ElideNone
+ maximumLineCount: 1
+ font.pixelSize: root.nameFontSize
+ // Missing File follows the Record, not the surface.
+ font.strikeout: root.missingFile
+ color: root.missingFile ? Theme.palette.baseColor1 : Theme.palette.directColor1
+ }
+
+ StatusBaseText {
+ Layout.fillWidth: true
+ visible: root.statusText.length > 0
+ text: root.statusText
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ font.pixelSize: root.statusFontSize
+ color: root.primaryAction === DownloadPill.PrimaryAction.Cancelled
+ ? Theme.palette.dangerColor1
+ : Theme.palette.baseColor1
+ }
+ }
+
+ // Figma "Fade": soft edge so filename never meets Cancel.
+ Rectangle {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ width: 24
+ visible: fileNameLabel.contentWidth > fileNameLabel.width - width
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop { position: 0.0; color: "transparent" }
+ GradientStop { position: 1.0; color: root.color }
+ }
+ }
+ }
+
+ // Fixed trailing slot so Cancel vs ⋮ does not shift the text column.
+ Item {
+ Layout.preferredWidth: 32
+ Layout.preferredHeight: 32
+ Layout.alignment: Qt.AlignVCenter
+
+ StatusFlatRoundButton {
+ id: cancelBtn
+ objectName: "downloadPillCancelButton"
+ anchors.centerIn: parent
+ width: 32
+ height: 32
+ visible: root.cancelButtonVisible
+ icon.name: "downloads-cancel"
+ type: StatusFlatRoundButton.Type.Tertiary
+ onClicked: root.triggerCancel()
+ }
+
+ StatusFlatRoundButton {
+ id: optionsBtn
+ objectName: "downloadPillOptionsButton"
+ anchors.centerIn: parent
+ width: 32
+ height: 32
+ visible: root.optionsButtonVisible
+ icon.name: "more-v"
+ type: StatusFlatRoundButton.Type.Tertiary
+ onClicked: root.optionsButtonClicked(optionsBtn)
+ }
+ }
+ }
+}
diff --git a/ui/app/AppLayouts/Browser/controls/qmldir b/ui/app/AppLayouts/Browser/controls/qmldir
index 3b4d378b55..2442619c43 100644
--- a/ui/app/AppLayouts/Browser/controls/qmldir
+++ b/ui/app/AppLayouts/Browser/controls/qmldir
@@ -1,4 +1,5 @@
BrowserAddressField 1.0 BrowserAddressField.qml
DownloadElement 1.0 DownloadElement.qml
+DownloadPill 1.0 DownloadPill.qml
BrowserHeaderButton 1.0 BrowserHeaderButton.qml
StatusBubble 1.0 StatusBubble.qml
diff --git a/ui/app/AppLayouts/Browser/panels/DownloadPillStrip.qml b/ui/app/AppLayouts/Browser/panels/DownloadPillStrip.qml
new file mode 100644
index 0000000000..e1ff00ff75
--- /dev/null
+++ b/ui/app/AppLayouts/Browser/panels/DownloadPillStrip.qml
@@ -0,0 +1,190 @@
+import QtQuick
+import QtQuick.Layouts
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Controls
+
+import AppLayouts.Browser.controls
+
+/**
+ * Download Pill strip (session-only — never fed from Download History).
+ * Mobile: under the address bar. Desktop: window footer.
+ * Newest pills insert at the left; existing pills animate right. Fixed width;
+ * overflow scrolls horizontally.
+ */
+Rectangle {
+ id: root
+
+ property var downloadsModel: []
+
+ readonly property int pillWidth: 227
+
+ /// Both signals carry the Download Record — the one identity vocabulary
+ /// for a download; no strip-index space to translate.
+ signal openDownloadClicked(var record)
+ /// anchor is the pill's ⋮ button — the menu right-aligns under (or over) it.
+ signal optionsClicked(var record, Item anchor)
+ signal close()
+
+ // Figma File download: pills sit flush on a tinted strip, no card gaps, no border.
+ color: Theme.palette.baseColor2
+ implicitHeight: 44
+
+ onDownloadsModelChanged: d.syncFromDownloadsModel()
+ Component.onCompleted: d.syncFromDownloadsModel()
+
+ QtObject {
+ id: d
+
+ readonly property int shiftDurationMs: 220
+
+ /// Mirrors DownloadPill.highlighted for a neighbour the delegate can't reach.
+ /// Index-based and bounds-checked — delegates outlive removals from the model.
+ function isHighlightedAt(index) {
+ if (index < 0 || index >= stripListModel.count)
+ return false
+ const record = stripListModel.get(index).record
+ return !!record && !record.isTerminal
+ }
+
+ /// Mirror the JS-array store model into a ListModel so insert(0) emits
+ /// rowsInserted + layout change — ListView can then run displaced animation.
+ /// Full array reassignment alone would reset the view with no shift.
+ function syncFromDownloadsModel() {
+ const next = root.downloadsModel || []
+ const nextLen = next.length
+
+ if (nextLen === 0) {
+ stripListModel.clear()
+ return
+ }
+
+ // Prepend of one Record: new item at [0], previous strip is the tail.
+ if (stripListModel.count > 0 && nextLen === stripListModel.count + 1) {
+ let matches = true
+ for (let i = 0; i < stripListModel.count; ++i) {
+ if (stripListModel.get(i).record !== next[i + 1]) {
+ matches = false
+ break
+ }
+ }
+ if (matches) {
+ stripListModel.insert(0, { record: next[0] })
+ listView.positionViewAtBeginning()
+ return
+ }
+ }
+
+ // Single removal (dismiss / clear one pill).
+ if (nextLen === stripListModel.count - 1 && stripListModel.count > 0) {
+ for (let i = 0; i < stripListModel.count; ++i) {
+ const rec = stripListModel.get(i).record
+ let found = false
+ for (let j = 0; j < nextLen; ++j) {
+ if (next[j] === rec) {
+ found = true
+ break
+ }
+ }
+ if (!found) {
+ stripListModel.remove(i)
+ return
+ }
+ }
+ }
+
+ // Fallback: rebuild without animation (initial bind / unexpected reorder).
+ stripListModel.clear()
+ for (let i = 0; i < nextLen; ++i)
+ stripListModel.append({ record: next[i] })
+ listView.positionViewAtBeginning()
+ }
+ }
+
+ ListModel {
+ id: stripListModel
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ ListView {
+ id: listView
+ objectName: "downloadPillListView"
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ orientation: ListView.Horizontal
+ clip: true
+ spacing: 0
+ boundsBehavior: Flickable.StopAtBounds
+ model: stripListModel
+
+ add: Transition {
+ NumberAnimation {
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: d.shiftDurationMs
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ displaced: Transition {
+ NumberAnimation {
+ property: "x"
+ duration: d.shiftDurationMs
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ removeDisplaced: Transition {
+ NumberAnimation {
+ property: "x"
+ duration: d.shiftDurationMs
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ delegate: DownloadPill {
+ id: pill
+
+ required property var record
+ required property int index
+
+ download: record
+ width: root.pillWidth
+ height: ListView.view.height
+
+ onItemClicked: root.openDownloadClicked(pill.record)
+ onOptionsButtonClicked: function (anchor) {
+ root.optionsClicked(pill.record, anchor)
+ }
+
+ // Figma divider: only between two blended (terminal) pills — a
+ // highlighted neighbour already separates them with its own card.
+ Rectangle {
+ width: 1
+ height: 16
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ color: Theme.palette.baseColor1
+ visible: pill.index > 0 && !pill.highlighted
+ && !d.isHighlightedAt(pill.index - 1)
+ }
+ }
+ }
+
+ StatusFlatRoundButton {
+ id: closeBtn
+ objectName: "downloadPillStripClose"
+ Layout.preferredWidth: 48
+ Layout.fillHeight: true
+ icon.name: "close"
+ type: StatusFlatRoundButton.Type.Quaternary
+ onClicked: root.close()
+ }
+ }
+}
diff --git a/ui/app/AppLayouts/Browser/panels/DownloadsListView.qml b/ui/app/AppLayouts/Browser/panels/DownloadsListView.qml
new file mode 100644
index 0000000000..c761b5578d
--- /dev/null
+++ b/ui/app/AppLayouts/Browser/panels/DownloadsListView.qml
@@ -0,0 +1,89 @@
+import QtQuick
+import QtQuick.Controls
+
+import StatusQ
+import StatusQ.Core
+import StatusQ.Core.Theme
+
+import AppLayouts.Browser.controls
+
+/**
+ * Downloads List for the tabs/bookmarks overview.
+ * Newest Records first. Each row is the shared DownloadPill delegate rendered
+ * flat — the state-to-controls matrix, wording, eliding and Missing File
+ * presentation all live in the pill.
+ */
+Item {
+ id: root
+
+ property var downloadsModel: []
+
+ /// Both signals carry the Download Record — the one identity vocabulary
+ /// for a download; no index space to translate.
+ signal openDownloadClicked(var record)
+ /// anchor is the row's ⋮ button — the menu right-aligns under it.
+ signal optionsClicked(var record, Item anchor)
+
+ // Length, not Array.isArray: createObject converts JS arrays so isArray fails
+ // while .length still reports Records.
+ readonly property int _count: downloadsModel && downloadsModel.length !== undefined
+ ? downloadsModel.length : 0
+
+ StatusListView {
+ id: listView
+ objectName: "downloadsListView"
+ anchors.fill: parent
+ model: root.downloadsModel
+ spacing: Theme.halfPadding
+ clip: true
+
+ delegate: ItemDelegate {
+ id: row
+ required property var modelData
+ required property int index
+
+ readonly property var record: modelData
+
+ width: ListView.view.width
+ height: 56
+ padding: Theme.halfPadding
+
+ background: Rectangle {
+ radius: Theme.radius
+ color: row.hovered ? Theme.palette.primaryColor3 : StatusColors.transparent
+ }
+
+ contentItem: DownloadPill {
+ download: row.record
+
+ // Flat-row chrome: the surrounding delegate owns hover and click.
+ interactive: false
+ pillColor: StatusColors.transparent
+ leadingSlotSize: 32
+ contentSpacing: Theme.padding
+ leadingMargin: 0
+ trailingMargin: 0
+ nameFontSize: Theme.fontSize(14)
+ statusFontSize: Theme.fontSize(12)
+
+ onOptionsButtonClicked: function (anchor) {
+ root.optionsClicked(row.record, anchor)
+ }
+ }
+
+ onClicked: root.openDownloadClicked(row.record)
+
+ HoverHandler {
+ cursorShape: hovered ? Qt.PointingHandCursor : undefined
+ }
+ }
+ }
+
+ StatusBaseText {
+ objectName: "downloadsListEmptyLabel"
+ visible: root._count === 0
+ anchors.centerIn: parent
+ text: qsTr("Downloaded files will appear here.")
+ color: Theme.palette.secondaryText
+ }
+}
diff --git a/ui/app/AppLayouts/Browser/panels/qmldir b/ui/app/AppLayouts/Browser/panels/qmldir
index 92081575b4..021c3a4dbf 100644
--- a/ui/app/AppLayouts/Browser/panels/qmldir
+++ b/ui/app/AppLayouts/Browser/panels/qmldir
@@ -4,6 +4,8 @@ FavoritesBar 1.0 FavoritesBar.qml
BrowserLandscapeToolbar 1.0 BrowserLandscapeToolbar.qml
BrowserPortraitToolbar 1.0 BrowserPortraitToolbar.qml
DownloadBar 1.0 DownloadBar.qml
+DownloadPillStrip 1.0 DownloadPillStrip.qml
+DownloadsListView 1.0 DownloadsListView.qml
BrowserTabView 1.0 BrowserTabView.qml
FindBar 1.0 FindBar.qml
MobileAddressBar 1.0 MobileAddressBar.qml
diff --git a/ui/app/AppLayouts/Browser/popups/BrowserLinkContextMenu.qml b/ui/app/AppLayouts/Browser/popups/BrowserLinkContextMenu.qml
new file mode 100644
index 0000000000..f6cd75c7d2
--- /dev/null
+++ b/ui/app/AppLayouts/Browser/popups/BrowserLinkContextMenu.qml
@@ -0,0 +1,73 @@
+import QtQuick
+
+import StatusQ.Popups
+import StatusQ.Core.Utils as SQUtils
+
+/**
+ * Long-press menu for a link and/or image in the mobile browser (ADR 0005
+ * "save link"). Fed by AbstractWebView.linkLongPressed; either URL may be
+ * empty, never both. Desktop WebEngine keeps its own context menu.
+ */
+StatusMenu {
+ id: root
+
+ property url linkUrl
+ property url imageUrl
+
+ /// Host Web View (Backend) that raised the long-press; the host routes
+ /// Download link through it (ADR 0005). Set by openAt.
+ property var hostView: null
+
+ /// Open at a view-local touch point: `position` is relative to
+ /// `parentItem` (the host view fills it).
+ function openAt(linkUrl, imageUrl, position, parentItem, hostView) {
+ root.linkUrl = linkUrl
+ root.imageUrl = imageUrl
+ root.hostView = hostView
+ root.parent = parentItem
+ root.x = position.x
+ root.y = position.y
+ root.open()
+ }
+
+ readonly property bool hasLink: linkUrl.toString() !== ""
+ readonly property bool hasImage: imageUrl.toString() !== ""
+
+ // Mobile: system share sheet. Desktop (unused today): copy.
+ readonly property string shareLabel: SQUtils.Utils.isMobile ? qsTr("Share link") : qsTr("Copy link")
+ readonly property string shareIcon: SQUtils.Utils.isMobile
+ ? (SQUtils.Utils.isIOS ? "share-ios" : "share-android")
+ : "copy"
+
+ signal openInNewTabRequested(url targetUrl)
+ signal shareUrlRequested(url targetUrl)
+ signal downloadRequested(url targetUrl)
+
+ StatusAction {
+ enabled: root.hasLink
+ icon.name: "browser"
+ text: qsTr("Open in new tab")
+ onTriggered: root.openInNewTabRequested(root.linkUrl)
+ }
+ StatusAction {
+ enabled: root.hasLink
+ icon.name: root.shareIcon
+ text: root.shareLabel
+ onTriggered: root.shareUrlRequested(root.linkUrl)
+ }
+ StatusAction {
+ enabled: root.hasLink
+ icon.name: "download"
+ text: qsTr("Download link")
+ onTriggered: root.downloadRequested(root.linkUrl)
+ }
+ StatusMenuSeparator {
+ visible: root.hasLink && root.hasImage
+ }
+ StatusAction {
+ enabled: root.hasImage
+ icon.name: "image"
+ text: qsTr("Download image")
+ onTriggered: root.downloadRequested(root.imageUrl)
+ }
+}
diff --git a/ui/app/AppLayouts/Browser/popups/DownloadRecordMenu.qml b/ui/app/AppLayouts/Browser/popups/DownloadRecordMenu.qml
new file mode 100644
index 0000000000..c5f7a6cecb
--- /dev/null
+++ b/ui/app/AppLayouts/Browser/popups/DownloadRecordMenu.qml
@@ -0,0 +1,153 @@
+import QtQuick
+
+import StatusQ.Popups
+import StatusQ.Core.Utils as SQUtils
+
+import AppLayouts.Browser.adapters
+
+/**
+ * One download menu for Download Pill and Downloads List.
+ * Identity is the Download Record; enablement comes from one `capabilities`
+ * object (BrowserDownloadsContext.capabilitiesFor) BOUND at the call site, so
+ * the menu can never show stale capabilities. State-derived
+ * booleans stay internal. Share vs Copy labels via capabilities.useShareLabels.
+ * StatusAction has no visible; StatusMenu hides disabled items by default.
+ */
+StatusMenu {
+ id: root
+
+ property var record: null
+
+ /// Pill strip opens grant session Dismiss; list opens do not.
+ /// Set by openAnchored options; read by the host's capabilities binding.
+ property bool forStrip: false
+
+ /// Open right-aligned with the ⋮ `anchor` it was invoked from;
+ /// options.above opens upward, options.forStrip marks a pill-strip open.
+ /// x/y stay bound: the menu's width is 0 until content is first laid out.
+ function openAnchored(record, anchor, options) {
+ root.forStrip = !!(options && options.forStrip)
+ root.record = record
+ root.parent = anchor
+ root.x = Qt.binding(() => anchor.width - root.width)
+ root.y = Qt.binding(() => (options && options.above) ? -root.height : anchor.height)
+ root.open()
+ }
+
+ /// { openInBrowser, shareFile, shareUrl, showInFolder, retry, dismiss,
+ /// downloadsEntry, useShareLabels } — bind it:
+ /// capabilities: ctx.capabilitiesFor(record, …)
+ property var capabilities: null
+
+ readonly property var _caps: root.capabilities ?? ({})
+
+ // Mobile: Share file / Share URL. Desktop: Copy file path / Copy URL.
+ readonly property bool _useShareLabels: !!_caps.useShareLabels
+
+ readonly property bool isCancelled: record?.state === AbstractWebView.DownloadState.DownloadCancelled ?? false
+ readonly property bool isComplete: record?.state === AbstractWebView.DownloadState.DownloadCompleted ?? false
+ readonly property bool isInterrupted: record?.state === AbstractWebView.DownloadState.DownloadInterrupted ?? false
+ readonly property bool isMissing: !!(record && record.missingFile)
+ readonly property bool isPaused: {
+ if (!record)
+ return false
+ return record.isPaused
+ || record.state === AbstractWebView.DownloadState.DownloadPaused
+ }
+ readonly property bool isActiveTransfer: {
+ if (!record || isComplete || isCancelled || isInterrupted)
+ return false
+ return record.state === AbstractWebView.DownloadState.DownloadInProgress
+ || record.state === AbstractWebView.DownloadState.DownloadRequested
+ || isPaused
+ }
+
+ readonly property string shareFileLabel: _useShareLabels ? qsTr("Share file") : qsTr("Copy file path")
+ readonly property string shareUrlLabel: _useShareLabels ? qsTr("Share URL") : qsTr("Copy URL")
+ readonly property string shareFileIcon: _useShareLabels
+ ? (SQUtils.Utils.isIOS ? "share-ios" : "share-android")
+ : "copy"
+ readonly property string shareUrlIcon: _useShareLabels
+ ? (SQUtils.Utils.isIOS ? "share-ios" : "share-android")
+ : "copy"
+
+ // Plain signals — callers already hold the menu's Record.
+ signal downloadsRequested()
+ signal showInFolderRequested()
+ signal shareFileRequested()
+ signal shareUrlRequested()
+ signal openInBrowserRequested()
+ signal retryRequested()
+ signal dismissRequested()
+
+ StatusAction {
+ // Pill strip only (Figma pill menu): opens the Downloads List section
+ // of the Open tabs overview. Absent in list menus — you are already there.
+ enabled: !!root._caps.downloadsEntry
+ icon.name: "download"
+ text: qsTr("Downloads")
+ onTriggered: root.downloadsRequested()
+ }
+ StatusMenuSeparator {
+ visible: !!root._caps.downloadsEntry
+ }
+ StatusAction {
+ enabled: isActiveTransfer && !isPaused
+ icon.name: "pause"
+ text: qsTr("Pause")
+ onTriggered: root.record.pause()
+ }
+ StatusAction {
+ enabled: isActiveTransfer && isPaused
+ icon.name: "play"
+ text: qsTr("Resume")
+ onTriggered: root.record.resume()
+ }
+ StatusAction {
+ enabled: isComplete && !!root._caps.openInBrowser
+ icon.name: "browser"
+ text: qsTr("Open in Browser")
+ onTriggered: root.openInBrowserRequested()
+ }
+ StatusAction {
+ enabled: isComplete && !!root._caps.shareFile
+ icon.name: root.shareFileIcon
+ text: root.shareFileLabel
+ onTriggered: root.shareFileRequested()
+ }
+ StatusAction {
+ enabled: (isComplete || isInterrupted || isCancelled) && !!root._caps.shareUrl
+ icon.name: root.shareUrlIcon
+ text: root.shareUrlLabel
+ onTriggered: root.shareUrlRequested()
+ }
+ StatusAction {
+ enabled: isComplete && !!root._caps.showInFolder
+ icon.name: "show"
+ text: qsTr("Show in folder")
+ onTriggered: root.showInFolderRequested()
+ }
+ StatusAction {
+ enabled: !!root._caps.retry
+ icon.name: "refresh"
+ text: qsTr("Retry")
+ onTriggered: root.retryRequested()
+ }
+ StatusMenuSeparator {
+ visible: isActiveTransfer || (!!root._caps.dismiss && (isComplete || isCancelled))
+ }
+ StatusAction {
+ enabled: isActiveTransfer
+ type: StatusAction.Type.Danger
+ icon.name: "downloads-cancel"
+ text: qsTr("Cancel")
+ onTriggered: root.record.cancel()
+ }
+ StatusAction {
+ // Pill strip only: remove Completed/Cancelled from the session strip.
+ enabled: !!root._caps.dismiss && (isComplete || isCancelled)
+ icon.name: "close"
+ text: qsTr("Dismiss")
+ onTriggered: root.dismissRequested()
+ }
+}
diff --git a/ui/app/AppLayouts/Browser/popups/TabsBookmarksOverviewModal.qml b/ui/app/AppLayouts/Browser/popups/TabsBookmarksOverviewModal.qml
index bd668e753b..a4b5f64e0c 100644
--- a/ui/app/AppLayouts/Browser/popups/TabsBookmarksOverviewModal.qml
+++ b/ui/app/AppLayouts/Browser/popups/TabsBookmarksOverviewModal.qml
@@ -16,12 +16,15 @@ import shared.controls
import SortFilterProxyModel
+import AppLayouts.Browser.panels
+
StatusDialog {
id: root
enum Mode {
OpenTabs,
- Bookmarks
+ Bookmarks,
+ Downloads
}
property int initialMode: TabsBookmarksOverviewModal.Mode.OpenTabs
@@ -42,7 +45,14 @@ StatusDialog {
signal deleteBookmarkRequested(string url)
signal bookmarkClicked(string url)
- title: mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.OpenTabs ? qsTr("Open tabs") : qsTr("Bookmarks")
+ // downloads
+ property var downloadsModel: []
+
+ // Carries the Download Record — the one identity vocabulary.
+ signal downloadClicked(var record)
+ signal downloadOptionsClicked(var record, Item anchor)
+
+ title: d.titleText
destroyOnClose: true
fillHeightOnBottomSheet: true
width: 560
@@ -54,6 +64,14 @@ StatusDialog {
QtObject {
id: d
+ readonly property string titleText: {
+ if (mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.Bookmarks)
+ return qsTr("Bookmarks")
+ if (mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.Downloads)
+ return qsTr("Downloads")
+ return qsTr("Open tabs")
+ }
+
// Tabs Overview
readonly property int cardWidth: 162
readonly property int cardHeight: 200
@@ -78,7 +96,7 @@ StatusDialog {
filters: SQUtils.SearchFilter {
roleName: "title"
searchPhrase: searchField.text
- enabled: searchField.visible
+ enabled: searchField.visible && mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.OpenTabs
}
}
@@ -89,7 +107,7 @@ StatusDialog {
SQUtils.SearchFilter {
roleName: "name"
searchPhrase: searchField.text
- enabled: searchField.visible
+ enabled: searchField.visible && mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.Bookmarks
},
ValueFilter {
roleName: "url"
@@ -107,7 +125,7 @@ StatusDialog {
id: searchField
Layout.fillWidth: true
- visible: searchButton.checked
+ visible: searchButton.checked && mainTabBar.currentIndex !== TabsBookmarksOverviewModal.Mode.Downloads
onVisibleChanged: clear()
placeholderText: mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.OpenTabs ? qsTr("Search in open tabs")
@@ -280,6 +298,15 @@ StatusDialog {
}
}
}
+
+ DownloadsListView {
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.min(root.availableHeight, 400)
+ Layout.fillHeight: true
+ downloadsModel: root.downloadsModel
+ onOpenDownloadClicked: record => root.downloadClicked(record)
+ onOptionsClicked: (record, anchor) => root.downloadOptionsClicked(record, anchor)
+ }
}
}
@@ -304,6 +331,9 @@ StatusDialog {
CustomSwitchButton {
icon.name: "bookmark"
}
+ CustomSwitchButton {
+ icon.name: "download"
+ }
}
}
rightButtons: ObjectModel {
@@ -313,6 +343,7 @@ StatusDialog {
icon.width: d.iconSize
icon.height: d.iconSize
checkable: true
+ visible: mainTabBar.currentIndex !== TabsBookmarksOverviewModal.Mode.Downloads
tooltip.text: qsTr("Search")
onToggled: searchField.focus = checked
}
@@ -320,6 +351,7 @@ StatusDialog {
icon.name: "add"
icon.width: d.iconSize
icon.height: d.iconSize
+ visible: mainTabBar.currentIndex === TabsBookmarksOverviewModal.Mode.OpenTabs
tooltip.text: qsTr("Add")
onClicked: {
root.addTabRequested()
diff --git a/ui/app/AppLayouts/Browser/popups/qmldir b/ui/app/AppLayouts/Browser/popups/qmldir
index 4ec02f907e..2217997d23 100644
--- a/ui/app/AppLayouts/Browser/popups/qmldir
+++ b/ui/app/AppLayouts/Browser/popups/qmldir
@@ -4,5 +4,7 @@ AddFavoriteModal 1.0 AddFavoriteModal.qml
FavoriteMenu 1.0 FavoriteMenu.qml
BrowserWalletMenu 1.0 BrowserWalletMenu.qml
DownloadMenu 1.0 DownloadMenu.qml
+DownloadRecordMenu 1.0 DownloadRecordMenu.qml
+BrowserLinkContextMenu 1.0 BrowserLinkContextMenu.qml
MobileSettingsMenu 1.0 MobileSettingsMenu.qml
TabsBookmarksOverviewModal 1.0 TabsBookmarksOverviewModal.qml
diff --git a/ui/app/AppLayouts/Browser/stores/DownloadsStore.qml b/ui/app/AppLayouts/Browser/stores/DownloadsStore.qml
index 35d9e20b16..5d1b5952bd 100644
--- a/ui/app/AppLayouts/Browser/stores/DownloadsStore.qml
+++ b/ui/app/AppLayouts/Browser/stores/DownloadsStore.qml
@@ -310,8 +310,8 @@ QtObject {
return true
}
- // Formatting (elideFileName) lives in
- // webview/DownloadFormatUtils.js — pure functions, imported by the pill.
+ // Formatting lives with the pill: elideFileName in
+ // webview/DownloadFormatUtils.js, the status wording in DownloadPill.qml.
/// Sanitize a suggested file name and resolve a free Download Target under downloadsDirectory.
/// Collisions with existing files or in-session Records get "(1)", "(2)", … suffixes.