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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 75 additions & 11 deletions faststack/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,63 @@ ApplicationWindow {
}
}

function toArray(value) {
if (value === null || value === undefined) {
return []
}

if (Array.isArray(value)) {
return value
}

var valueType = typeof value
if (valueType === "string" || valueType === "number"
|| valueType === "boolean" || valueType === "function") {
return []
}

if (typeof value.length === "number") {
var result = []
for (var i = 0; i < value.length; ++i) {
result.push(value[i])
}
return result
}

return []
}

function stringOrEmpty(value) {
if (typeof value === "string") {
return value
}

if (value === null || value === undefined) {
return ""
}

if (typeof value === "number" || typeof value === "boolean") {
return String(value)
}

// Avoid rendering unexpected objects as "[object Object]".
return ""
}

function itemsWithStatus(items, status) {
var source = root.toArray(items)
var result = []

for (var i = 0; i < source.length; ++i) {
var item = source[i]
if (item && item.status === status) {
result.push(item)
}
}

return result
}


// -------- CUSTOM TITLE BAR --------
property int titleBarHeight: 36
Expand Down Expand Up @@ -1209,8 +1266,10 @@ ApplicationWindow {
color: root.currentTextColor
}
Label {
visible: (root.uiStateRef && root.uiStateRef.imageCount > 0 && root.uiStateRef.exifBrief && root.uiStateRef.exifBrief.length > 0)
text: root.uiStateRef ? (root.uiStateRef.exifBrief || "") : ""
visible: root.uiStateRef
&& root.uiStateRef.imageCount > 0
&& root.stringOrEmpty(root.uiStateRef.exifBrief).length > 0
text: root.uiStateRef ? root.stringOrEmpty(root.uiStateRef.exifBrief) : ""
color: root.currentTextColor
}
Label {
Expand Down Expand Up @@ -1318,11 +1377,16 @@ ApplicationWindow {
}
// Variant badges (loupe view only, when multiple variants exist)
Row {
id: variantBadgeRow
property var badgeItems: root.toArray(root.uiStateRef ? root.uiStateRef.variantBadges : null)

spacing: 4
visible: root.uiStateRef && !root.uiStateRef.isGridViewActive && root.uiStateRef.variantBadges.length > 1
visible: root.uiStateRef
&& !root.uiStateRef.isGridViewActive
&& variantBadgeRow.badgeItems.length > 1

Repeater {
model: root.uiStateRef ? root.uiStateRef.variantBadges : []
model: variantBadgeRow.badgeItems

delegate: Rectangle {
id: variantBadge
Expand Down Expand Up @@ -1767,6 +1831,9 @@ ApplicationWindow {
// Single source of truth for per-bin restore info.
// Populated on open and after each restore action.
property var binInfo: []
property var binInfoItems: root.toArray(binInfo)
property var restorableBins: root.itemsWithStatus(binInfoItems, "restorable")
property var unavailableBins: root.itemsWithStatus(binInfoItems, "unavailable")

function refreshBinInfo() {
if (root.uiStateRef) {
Expand Down Expand Up @@ -1826,7 +1893,7 @@ ApplicationWindow {
// ---- Per-bin restore rows (restorable bins only) ----
Repeater {
id: restorableRepeater
model: recycleBinCleanupDialog.binInfo.filter(function(b) { return b.status === "restorable" })
model: recycleBinCleanupDialog.restorableBins

delegate: Rectangle {
id: restorableBin
Expand Down Expand Up @@ -1907,7 +1974,7 @@ ApplicationWindow {
root.uiStateRef.restoreSingleBin(restorableBin.modelData.bin_path)
recycleBinCleanupDialog.refreshBinInfo()
// Auto-close if nothing left
if (recycleBinCleanupDialog.binInfo.length === 0) {
if (recycleBinCleanupDialog.binInfoItems.length === 0) {
recycleBinCleanupDialog.close()
}
}
Expand All @@ -1924,10 +1991,7 @@ ApplicationWindow {
Column {
width: dialogContent.width - 40
spacing: 6
visible: {
var items = recycleBinCleanupDialog.binInfo.filter(function(b) { return b.status === "unavailable" })
return items.length > 0
}
visible: recycleBinCleanupDialog.unavailableBins.length > 0

Label {
text: "Not auto-restorable (legacy format)"
Expand All @@ -1944,7 +2008,7 @@ ApplicationWindow {
}

Repeater {
model: recycleBinCleanupDialog.binInfo.filter(function(b) { return b.status === "unavailable" })
model: recycleBinCleanupDialog.unavailableBins

delegate: Label {
id: unavailableBin
Expand Down
18 changes: 18 additions & 0 deletions faststack/tests/test_main_qml_runtime_guards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pathlib import Path


def test_main_qml_coerces_runtime_values_before_array_operations():
"""Keep startup bindings from calling JS array APIs on raw backend values."""
qml_path = Path(__file__).resolve().parents[1] / "qml" / "Main.qml"
qml_text = qml_path.read_text(encoding="utf-8")

assert "function toArray(value)" in qml_text
assert "function stringOrEmpty(value)" in qml_text
assert "function itemsWithStatus(items, status)" in qml_text
assert "value === null || value === undefined" in qml_text
assert "if (!value)" not in qml_text

assert "root.uiStateRef.variantBadges.length" not in qml_text
assert "recycleBinCleanupDialog.binInfo.filter(" not in qml_text
assert "recycleBinCleanupDialog.binInfo.length" not in qml_text
assert "root.stringOrEmpty(root.uiStateRef.exifBrief)" in qml_text
Comment thread
coderabbitai[bot] marked this conversation as resolved.
20 changes: 20 additions & 0 deletions faststack/tests/test_recycle_bin_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,23 @@ def test_get_recycle_bin_stats_untracked_existing_bin(app_controller):
assert stats[0]["count"] == 1
# Check that it was auto-added to active_recycle_bins for future cleanup
assert recycle_bin in app_controller.active_recycle_bins


def test_get_per_bin_restore_info_returns_fresh_values(app_controller):
"""Each refresh should produce fresh containers so QML bindings can update."""
recycle_bin = app_controller.image_dir / "image recycle bin"
recycle_bin.mkdir(parents=True)
(recycle_bin / "photo._fs_deadbeef.jpg").touch()
app_controller.active_recycle_bins.add(recycle_bin)

first = app_controller.get_per_bin_restore_info()
second = app_controller.get_per_bin_restore_info()

assert first == second
assert first is not second
assert first[0] is not second[0]
assert second[0]["status"] == "restorable"

first[0]["status"] = "mutated"

assert second[0]["status"] == "restorable"
Loading