Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Files (`folio`) is Omarchy's file manager. It's one quiet window that follows your Omarchy theme and language, works from the keyboard, and never crawls your home folder in the background: folders are read when you open them, and thumbnails are made when a tile needs one, shared with every other app through `~/.cache/thumbnails`.

- **Three views**: a grid with real previews, a list, and Finder-style columns. Ctrl+1/2/3 switches.
The list can show a tree, where → and ← open folders in place; it's off until T (or the tree button beside the view button).
- **Space previews** pictures, video, audio, PDFs, text and Markdown at full size, in their own window.
- **The sidebar** holds everything in your home folder, pinned folders (shared with GTK file dialogs), Recents, Trash with restore, your drives, and the machines on your tailnet, which open over SFTP.
- **Right-click** (or Alt+Enter) to open with another app, rename, duplicate, compress, share by Dropbox link, Taildrop or LocalSend, and delete. Copy and paste work with other apps, and nothing is ever overwritten.
Expand All @@ -20,6 +21,7 @@ Files (`folio`) is Omarchy's file manager. It's one quiet window that follows yo
| Key | Does |
| --- | --- |
| Arrows, ⏎ | Move, open |
| → / ← in the list, with the tree on | Open a folder in place, close it |
| Space | Preview |
| Backspace, Alt+← / Alt+→ | Up, back, forward |
| Ctrl+L | Type a path |
Expand All @@ -28,6 +30,7 @@ Files (`folio`) is Omarchy's file manager. It's one quiet window that follows yo
| F2, Delete | Rename, move to the trash |
| Ctrl+Shift+N | New folder |
| Ctrl+P | Pin the folder |
| T | Tree in the list |
| Ctrl+N, Ctrl+Alt+N | New window, split |
| Tab, Shift+Tab | Sidebar, panes and details |
| ? | Every other shortcut |
Expand Down
94 changes: 92 additions & 2 deletions src/FileRow.qml
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,24 @@ Item {
required property int itemCount
required property string icon
required property bool selected
required property int depth
required property bool open
required property string lines

property bool current: false
// In a column to the left: the folder the next column is showing.
property bool trail: false
property bool detailed: false
property bool chevron: false
// In the list with the tree on: rows indented by how deep in the tree
// they are, joined by thin lines to the folder they're in, and an arrow
// on folders to open them in place. Off, the row is as it always was.
property bool tree: false

signal clicked(int modifiers)
signal activated()
// The arrow was clicked (or pressed by a screen reader).
signal toggled()

implicitHeight: 32

Expand Down Expand Up @@ -69,12 +78,83 @@ Item {
: "transparent"
}

// The lines, one 16 px step for each level in, like the tree command
// draws them: the vertical ones run the row's full height so they join
// the rows above and below. Drawn left to right and flipped whole when
// mirrored, so they lead from the arrows on the other side.
Item {
objectName: "treeLines"
visible: row.tree && row.depth > 0
anchors.left: parent.left
anchors.leftMargin: 14
anchors.top: parent.top
anchors.bottom: parent.bottom
width: row.depth * 16 + 16
transform: Scale { xScale: row.LayoutMirroring.enabled ? -1 : 1; origin.x: (row.depth * 16 + 16) / 2 }
Repeater {
model: row.tree ? row.lines.split("") : []
Item {
required property int index
required property string modelData
readonly property bool elbow: modelData === "├" || modelData === "└"
x: index * 16
width: 16
height: parent.height
Rectangle {
objectName: "treeGuide"
visible: modelData !== " "
x: 7
width: 1
height: modelData === "└" ? Math.round(parent.height / 2) : parent.height
color: Qt.alpha(theme.foreground, 0.25)
}
// Over to this row's arrow, or for a file, which has none, on
// through the arrow's room to its icon.
Rectangle {
visible: parent.elbow
x: 8
y: Math.round(parent.height / 2)
width: row.isDir ? 6 : 20
height: 1
color: Qt.alpha(theme.foreground, 0.25)
}
}
}
}

RowLayout {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 12

// One step in for each open folder above, then the arrow. A file has
// the same room without one, so the names line up.
Item {
visible: row.tree
implicitWidth: row.depth * 16 + 16
implicitHeight: 16
Item {
id: disclosure
objectName: "disclosure"
visible: row.isDir
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
Accessible.role: Accessible.Button
Accessible.name: row.open ? qsTr("Collapse %1").arg(row.name) : qsTr("Expand %1").arg(row.name)
Accessible.onPressAction: row.toggled()
// Points along the row, and down once the folder is open.
Glyph {
anchors.centerIn: parent
name: row.LayoutMirroring.enabled ? "back" : "forward"
rotation: row.open ? (row.LayoutMirroring.enabled ? -90 : 90) : 0
size: 12
color: theme.secondaryForeground
}
}
}
Item {
implicitWidth: 20
implicitHeight: 20
Expand Down Expand Up @@ -113,6 +193,7 @@ Item {
// mirrored: left as they are, they'd follow their own script, and a
// Latin name would sit at the far end of a right-to-left row.
Text {
objectName: "rowName"
Layout.fillWidth: true
Layout.minimumWidth: 120
horizontalAlignment: Text.AlignLeft
Expand Down Expand Up @@ -154,10 +235,19 @@ Item {
id: hover
onHoveredChanged: if (hovered) row.grabToImage(result => row.Drag.imageSource = result.url)
}
// A click on the arrow (or a little either side of it, the row's full
// height) only opens or closes the folder: one handler for the whole
// row, so the click is never taken as both.
function atArrow(position) {
if (!row.tree || !disclosure.visible)
return false
const at = disclosure.mapFromItem(row, position.x, position.y)
return at.x >= -6 && at.x < disclosure.width + 6
}
TapHandler {
id: tap
onTapped: row.clicked(tap.point.modifiers)
onDoubleTapped: row.activated()
onTapped: row.atArrow(tap.point.position) ? row.toggled() : row.clicked(tap.point.modifiers)
onDoubleTapped: if (!row.atArrow(tap.point.position)) row.activated()
}

// Like the tiles: dragged, a row is a file for the sidebar or another app.
Expand Down
104 changes: 97 additions & 7 deletions src/FolderList.qml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import QtQuick.Controls
ListView {
id: list

// Whether folders open in place (the window's tree, T). Off, the
// list is a plain list: → and ← go into and out of folders, as in the
// columns. Turned off, the open folders close, and the rows shifting
// put the cursor on the folder at the top its item was in.
property bool tree: false
onTreeChanged: if (!tree) backend.folder.collapseAll()

function select(row, keep) { selecting.select(row, keep) }
// A click on empty space lets go of the cursor; the pane then keeps it
// let go of through a re-read or a switch of view.
Expand All @@ -17,22 +24,97 @@ ListView {

Selecting { id: selecting; view: list }
onCurrentIndexChanged: selecting.cursorMoved()

// Rows coming and going in place (a folder opened or closed above or
// around the cursor, its items arriving, a file appearing, a new order)
// would move the cursor off its item, and the cursor moving selects
// what it lands on. So the rows are laid out at once, quietly, and the
// cursor put back on its item; when that went out of sight with a
// folder closed, on the folder, which is then selected if nothing is.
property string shiftingPath: ""
property string shiftingAnchor: ""
Connections {
target: backend.folder
function onRowsShifting() {
list.shiftingPath = backend.folder.get(list.currentIndex).path || ""
list.shiftingAnchor = backend.folder.get(selecting.anchor).path || ""
}
function onRowsShifted() {
const folder = backend.folder
selecting.quiet = true
list.forceLayout()
selecting.quiet = false
if (!list.shiftingPath)
return
const shown = folder.path.endsWith("/") ? folder.path : folder.path + "/"
let row = folder.indexOfPath(list.shiftingPath)
const hidden = row < 0
for (let up = list.shiftingPath; row < 0 && up.startsWith(shown);) {
up = up.substring(0, up.lastIndexOf("/"))
row = folder.indexOfPath(up)
Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Rename loses the selected file

Renaming a nested file moves the cursor to its parent instead of keeping the renamed file selected. FileOps::rename() reports the actual parent folder, but FolderPane.onDone ignores that result because it differs from backend.path. When the child listing arrives, this fallback treats the old name as a removed file and selects its parent. Follow completed changes inside open folders by their full destination path before using the parent fallback.

Knowledge Base Used: Folder view layouts

}
// Gone from the folder itself (trashed, moved away): the cursor
// stays on its row, and the folder's re-read places it as before.
if (row < 0)
return
if (row !== list.currentIndex)
selecting.moveTo(row)
if (hidden) {
selecting.anchor = row
if (folder.selectedCount === 0)
folder.selectOnly(row)
} else {
selecting.anchor = folder.indexOfPath(list.shiftingAnchor)
}
}
}
Keys.onPressed: (event) => selecting.extendBy(event, event.key === Qt.Key_Up ? -1 : event.key === Qt.Key_Down ? 1 : 0)

Band {
view: list
rectOf: (i) => Qt.rect(0, i * 32, list.width, 32)
}

// → goes into the selected folder and ← back up, as in the columns;
// mirrored, the other way round, with the reading direction.
function enter() {
const item = backend.folder.get(currentIndex)
if (item.isDir)
// With the tree on, → opens the cursor's folder in place, below it, and on an open one
// steps to its first item; ← closes it, or steps out to the folder it's
// in, and at the top goes back up as before. Mirrored, the other way
// round, with the reading direction. Where nothing nests (a search,
// Recents, a plugin's view) → goes into the folder, as in the columns.
function inward() {
const folder = backend.folder
const item = folder.get(currentIndex)
if (!item.isDir)
return
if (!tree) {
backend.navigate(item.path)
} else if (item.open) {
// Its items may still be on their way; until they're there, the
// cursor stays put.
if (currentIndex + 1 < count && folder.get(currentIndex + 1).depth > item.depth)
moveCursor(currentIndex + 1)
} else if (!folder.expand(currentIndex)) {
backend.navigate(item.path)
}
}
function outward() {
const folder = backend.folder
const item = folder.get(currentIndex)
if (!tree)
backend.goUp()
else if (item.open)
folder.collapse(currentIndex)
else if (folder.parentRow(currentIndex) >= 0)
moveCursor(folder.parentRow(currentIndex))
else
backend.goUp()
}
// Moved like the arrows move it, so what it lands on is selected.
function moveCursor(row) {
currentIndex = row
positionViewAtIndex(row, ListView.Contain)
}
Keys.onRightPressed: LayoutMirroring.enabled ? backend.goUp() : enter()
Keys.onLeftPressed: LayoutMirroring.enabled ? enter() : backend.goUp()
Keys.onRightPressed: LayoutMirroring.enabled ? outward() : inward()
Keys.onLeftPressed: LayoutMirroring.enabled ? inward() : outward()

// Right-click on an item (selecting it) or on empty space.
signal contextMenu(var item, string folder, point at)
Expand All @@ -56,8 +138,16 @@ ListView {
delegate: FileRow {
width: list.width
detailed: true
tree: list.tree

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Folder arrows do nothing

Folder arrows remain visible in search results and Recents when the tree is on, but clicking them does nothing. The delegate receives list.tree without checking whether the current model can nest, while expand() rejects both views. Hide the tree controls there, or give the arrow the same navigation fallback as the right-arrow key.

current: selected
onClicked: (modifiers) => { selecting.click(index, modifiers); list.forceActiveFocus() }
// The arrow opens or closes the folder in place, leaving the folder
// shown and the selection as they are.
onToggled: {
if (list.tree)
open ? backend.folder.collapse(index) : backend.folder.expand(index)
list.forceActiveFocus()
}
onActivated: backend.open(path)
}

Expand Down
41 changes: 39 additions & 2 deletions src/FolderPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,40 @@ FocusScope {
// remembered between launches; the right pane of a split starts with
// the view of the pane it was split from.
property string viewMode: host ? host.viewMode : "columns"
onViewModeChanged: if (host && !closable) host.viewMode = viewMode
onViewModeChanged: {
if (host && !closable)
host.viewMode = viewMode
if (viewMode !== "list")
leaveTree()
}
// Folders open in place are the list's own: the grid and the columns
// show the folder's items alone, so switching away closes them. The
// cursor goes to the folder its item was in, at the top.
function leaveTree() {
let row = backend.folder.indexOfPath(lastPath)
while (row >= 0 && backend.folder.parentRow(row) >= 0)
row = backend.folder.parentRow(row)
const path = row >= 0 ? backend.folder.get(row).path : lastPath
const rows = backend.folder.count
backend.folder.collapseAll()
if (backend.folder.count === rows)
return
lastPath = path
if (view.item && !cursorLetGo)
view.item.select(backend.folder.indexOfPath(path), true)
}
// Whether the list opens folders in place, shared by the window's panes
// and remembered with the view.
readonly property bool treeView: host ? !!host.treeView : false
// T and the tree button. The key says what it did in the banner, as
// the button can't be seen to light from the keyboard's side.
function toggleTree(say) {
if (!host)
return
host.treeView = !host.treeView
if (say)
backend.showNotice(host.treeView ? qsTr("Tree on") : qsTr("Tree off"))
}
// How big the grid's tiles are, in steps (Ctrl+= / Ctrl+- / Ctrl+0, or
// Ctrl+scroll), shared and remembered the same way as the view.
property real zoom: host ? host.zoom : 1
Expand Down Expand Up @@ -198,6 +231,8 @@ FocusScope {
// S steps through the sort orders, Shift+S back.
Shortcut { sequence: "S"; enabled: pane.keys; onActivated: pathBar.stepSort(1) }
Shortcut { sequence: "Shift+S"; enabled: pane.keys; onActivated: pathBar.stepSort(-1) }
// T turns the list's tree on and off; the grid and the columns have none.
Shortcut { sequence: "T"; enabled: pane.keys && pane.viewMode === "list"; onActivated: pane.toggleTree(true) }
Shortcut { sequences: ["F5", "Refresh"]; enabled: pane.active; onActivated: { backend.folder.reload(); places.refresh(); plugins.refresh() } }

// A drive being ejected can't be in use: a pane showing a folder on it
Expand Down Expand Up @@ -413,7 +448,7 @@ FocusScope {
}
}
Component { id: gridView; FolderGrid { zoom: pane.zoom; onZoomRequested: (step) => pane.zoomBy(step) } }
Component { id: listView; FolderList { } }
Component { id: listView; FolderList { tree: pane.treeView } }
Component { id: columnsView; FolderColumns { } }
}

Expand All @@ -431,7 +466,9 @@ FocusScope {
viewMode: pane.viewMode
closable: pane.closable
onTogglePreview: if (pane.host) pane.host.previewVisible = !pane.host.previewVisible
treeView: pane.treeView
onCycleView: pane.cycleView()
onTreeToggled: pane.toggleTree(false)
onCloseRequested: pane.closeRequested()
}

Expand Down
3 changes: 3 additions & 0 deletions src/Glyph.qml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ Item {
case "grid": return box(4, 4, 6, 6, r / 2) + box(14, 4, 6, 6, r / 2) + box(4, 14, 6, 6, r / 2) + box(14, 14, 6, 6, r / 2)
case "list": return "M4 6h16M4 12h16M4 18h16"
case "columns": return box(3, 4, 18, 16, r) + "M9 4v16M15 4v16"
// A folder's row, and under it two rows open in place, joined by
// the tree's lines.
case "tree": return "M4 6h10M7 6v12h4M7 12h4M14 12h6M14 18h6"
case "preview": return box(3, 5, 18, 14, r) + "M14 5v14"
// One per sort order: a slim arrow on the left (down for the usual
// direction, up for oldest first), and what it sorts by on the right,
Expand Down
2 changes: 2 additions & 0 deletions src/HelpOverlay.qml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Rectangle {
property var shortcuts: [
["← ↑ → ↓", qsTr("Move the selection")],
[LayoutMirroring.enabled ? "← / →" : "→ / ←", qsTr("In list and columns: into a folder / back out")],
[LayoutMirroring.enabled ? "← / →" : "→ / ←", qsTr("With the tree on: open a folder in place / close it or step out")],
// The keys in these few are mixed with words; the key names (Shift,
// Alt, ⏎) stay as they are in every language.
[qsTr("Shift+click / Shift+arrows"), qsTr("Select a range")],
Expand Down Expand Up @@ -63,6 +64,7 @@ Rectangle {
["Escape", qsTr("Stop the file work under way, or dismiss what went wrong")],
[qsTr("Right-click / Alt+⏎ / Menu"), qsTr("Everything else, for files and for drives in the sidebar")],
["S / Shift+S", qsTr("Sort: name, kind, newest, oldest, size")],
["T", qsTr("In the list: the tree, folders opening in place, on or off")],
["Ctrl+H", qsTr("Show hidden files")],
["F5", qsTr("Reload")],
["?", qsTr("Show these shortcuts")],
Expand Down
Loading