diff --git a/.github/README.md b/.github/README.md
index a63e931ced55..50abdbf455fb 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -32,6 +32,14 @@ cd LinuxFiles
scripts/linux/install-local.sh
```
+### Nightly builds
+
+Every merge to `main` publishes a tarball and AppImage to the rolling [nightly pre-release](https://github.com/MemerGamer/LinuxFiles/releases/tag/nightly). Run it with:
+
+```sh
+curl -LO https://github.com/MemerGamer/LinuxFiles/releases/download/nightly/LinuxFiles-nightly-x86_64.AppImage && chmod +x LinuxFiles-nightly-x86_64.AppImage && ./LinuxFiles-nightly-x86_64.AppImage
+```
+
See [docs/linux-port/packaging.md](../docs/linux-port/packaging.md) for packaging details and [docs/linux-port/PLAN.md](../docs/linux-port/PLAN.md) for the roadmap.
## Reporting bugs
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 8ba1364847f0..e727e09bca9e 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -1,6 +1,6 @@
# Security Policy
-LinuxFiles is an unofficial, community-maintained Linux port of [Files](https://github.com/files-community/Files). It is in alpha, and only the latest release and the `linux/main` branch receive fixes.
+LinuxFiles is an unofficial, community-maintained Linux port of [Files](https://github.com/files-community/Files). It is in alpha, and only the latest release and the `main` branch receive fixes.
## Reporting a vulnerability in LinuxFiles
diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml
index 6bfdfa6438c8..2f1a798bc881 100644
--- a/.github/workflows/ci-linux.yml
+++ b/.github/workflows/ci-linux.yml
@@ -71,9 +71,17 @@ jobs:
- name: Build Files.App.Storage (Uno desktop)
run: dotnet build src/Files.App.Storage/Files.App.Storage.csproj -c Release -f net10.0-desktop
- # Informational until the Phase 2 error inventory (docs/linux-port/phase2-errors.md) reaches zero.
- - name: Build Files.App (Uno desktop)
- run: dotnet build src/Files.App/Files.App.csproj -c Release -f net10.0-desktop
+ # Blocking: Win32 compat mode is off (the default since P4-Z), so no Win32/Shell code and no CsWin32 assembly may be needed.
+ - name: Build Files.App (Uno desktop, Win32 compat off)
+ run: dotnet build src/Files.App/Files.App.csproj -c Release -f net10.0-desktop -p:FilesWin32Compat=false
+
+ - name: Verify the desktop build did not build CsWin32
+ shell: bash
+ run: |
+ if [ -d src/Files.App.CsWin32/bin ] || [ -d src/Files.App.CsWin32/obj ]; then
+ echo "::error::Files.App.CsWin32 was built for the desktop target; something still depends on Win32 compat mode."
+ exit 1
+ fi
- name: Build and test platform test projects (if any)
shell: bash
diff --git a/.github/workflows/nightly-linux.yml b/.github/workflows/nightly-linux.yml
new file mode 100644
index 000000000000..9a18795488ee
--- /dev/null
+++ b/.github/workflows/nightly-linux.yml
@@ -0,0 +1,119 @@
+# Copyright (c) Files Community
+# Licensed under the MIT License.
+
+# Abstract:
+# Builds the self-contained linux-x64 tarball and AppImage on every push to main and publishes them to
+# one rolling "nightly" GitHub pre-release with stable asset names. Pull requests targeting main get the
+# same build as workflow artifacts only (read-only token). The Flatpak is left to package-linux.yml.
+
+name: Nightly Linux
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || 'nightly' }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+
+ build-linux-x64:
+
+ runs-on: ubuntu-latest
+
+ steps:
+
+ - name: Checkout the repository
+ uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: global.json
+
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
+ - name: Publish self-contained linux-x64
+ run: scripts/linux/publish.sh --rid linux-x64 --tarball
+
+ - name: Build AppImage
+ run: packaging/linux/appimage/build-appimage.sh
+
+ - name: Stage assets with stable names
+ run: |
+ mkdir -p dist
+ cp artifacts/files-linux-x64.tar.gz dist/LinuxFiles-nightly-linux-x64.tar.gz
+ cp artifacts/Files-x86_64.AppImage dist/LinuxFiles-nightly-x86_64.AppImage
+
+ - name: Upload packages
+ uses: actions/upload-artifact@v4
+ with:
+ name: linuxfiles-nightly-linux-x64
+ path: dist/*
+ if-no-files-found: error
+
+ publish-nightly:
+
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
+ needs: build-linux-x64
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+
+ - name: Checkout the repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Download packages
+ uses: actions/download-artifact@v4
+ with:
+ name: linuxfiles-nightly-linux-x64
+ path: dist
+
+ - name: Replace the nightly release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ SHA: ${{ github.sha }}
+ run: |
+ short="${SHA:0:7}"
+
+ # Commit list since the previous nightly (falls back to the last 20 commits).
+ prev="$(gh api "repos/$REPO/git/ref/tags/nightly" --jq '.object.sha' 2>/dev/null || true)"
+ if [ -n "$prev" ] && git cat-file -e "$prev^{commit}" 2>/dev/null; then
+ range="$prev..$SHA"
+ else
+ range="-20 $SHA"
+ fi
+ {
+ echo "Rolling build of \`main\` at $SHA. Replaced on every push; not for production use."
+ echo
+ echo "Run the AppImage: \`chmod +x LinuxFiles-nightly-x86_64.AppImage && ./LinuxFiles-nightly-x86_64.AppImage\`"
+ echo
+ echo "### Changes since the previous nightly"
+ # shellcheck disable=SC2086
+ git log --no-merges --format='- %h %s' $range
+ } > notes.md
+
+ gh release delete nightly -R "$REPO" --cleanup-tag --yes 2>/dev/null || true
+ gh release create nightly -R "$REPO" --target "$SHA" --prerelease \
+ --title "Nightly (main @ $short)" --notes-file notes.md \
+ dist/LinuxFiles-nightly-linux-x64.tar.gz dist/LinuxFiles-nightly-x86_64.AppImage
diff --git a/Directory.Build.props b/Directory.Build.props
index 3db5d9e4871f..1c84d532f745 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -9,8 +9,8 @@
10.0.26100.67-preview
$(BaseTargetFramework)-windows$(TargetWindowsVersion)
$(BaseTargetFramework)-desktop
-
- true
+
+ false
preview
nullable
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 491811286e8a..5dc4381756c8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -45,6 +45,7 @@
+
diff --git a/docs/linux-port/AGENT-RULES.md b/docs/linux-port/AGENT-RULES.md
index 690f7f22a517..803fa7c4bc58 100644
--- a/docs/linux-port/AGENT-RULES.md
+++ b/docs/linux-port/AGENT-RULES.md
@@ -16,10 +16,10 @@ Read this first, then `PLAN.md` Β§0, then `CLAUDE.md`.
- Never commit anything from the real home directory.
## Git / GitHub
-- Branch from `origin/linux/main`: `git fetch origin && git switch -c linux/ origin/linux/main`.
+- Branch from `origin/main` (the single integration branch; releases are tags: `nightly` rolling, `linux-v*` stable): `git fetch origin && git switch -c linux/ origin/main`.
- Commit trailer: `Co-Authored-By: Claude Sonnet 5.5 `.
- Push: `git -c credential.helper='!gh auth git-credential' push -u origin linux/`.
-- PR: `gh pr create -R MemerGamer/LinuxFiles --base linux/main --head linux/ ...`. The body ends with `π€ Generated with [Claude Code](https://claude.com/claude-code)`.
+- PR: `gh pr create -R MemerGamer/LinuxFiles --base main --head linux/ ...`. The body ends with `π€ Generated with [Claude Code](https://claude.com/claude-code)`.
- Never target, push to, or comment on upstream `files-community/Files`.
## Code
diff --git a/docs/linux-port/PLAN.md b/docs/linux-port/PLAN.md
index 89d443d8d8bc..f58ae059a205 100644
--- a/docs/linux-port/PLAN.md
+++ b/docs/linux-port/PLAN.md
@@ -1,5 +1,7 @@
# Files on Linux β Porting Plan
+> **Branching (2026-10-05):** `main` is the single integration branch. `linux/main` is retired (frozen at the last merge). Releases are tags: the rolling `nightly` pre-release (every push to `main`) and stable `linux-v*` tags.
+
Status: draft v1 Β· 2026-10-04 Β· fork base `0e3c17ca4` (in sync with `files-community/Files` main)
## 0. Revision 2 (2026-10-04): Linux-first
@@ -43,7 +45,7 @@ The owner decided to focus on Linux first. Windows support is revisited only aft
| 1 | Abstraction skeleton (`Files.Platform.*`, tests, DI) | serial, small |
| 2 | **Linux bring-up: make `Files.App` compile and launch on Uno `net10.0-desktop`.** Retarget Controls + App to Uno. Exclude Windows-only folders (`Compile Remove`). Replace Win32/WinRT calls with interface calls, backed by stub or simple Linux implementations. Fix compile errors folder by folder. **β
Done (2026-10-05):** the app compiles, launches and runs on Linux; the compat-off criterion moved to Phase 4. | parallel by folder ownership |
| 3 | Real Linux backends per interface (enumeration, watcher, file ops, trash, launcher, thumbnails, drives, clipboardβ¦), with unit tests | parallel, one agent per interface |
-| 4 | Replace the legacy WinRT storage layer (`BaseStorageFile`/`Folder`) with OwlCore storables, then switch Win32 compat mode off (moved from Phase 2) | parallel work packages (see Phase 4 plan, Revision 3) |
+| 4 | Replace the legacy WinRT storage layer (`BaseStorageFile`/`Folder`) with OwlCore storables, then switch Win32 compat mode off (moved from Phase 2). **β
Done (2026-10-05, P4-Z):** compat mode is off by default and the desktop build has 0 errors, which also meets Phase 2's criterion | parallel work packages (see Phase 4 plan, Revision 3) |
| 5 | UI blockers and UX parity: L-DND, SpeedGraph, StickyHeader, title bar, previews, single instance | parallel |
| 6 | Desktop integration and packaging (FileManager1 D-Bus, `.desktop`, Flatpak/AppImage/AUR) | parallel |
| 7 | Hardening (performance, HiDPI, themes, localisation) | parallel |
@@ -53,7 +55,7 @@ The Phase 2 workstreams in Β§7.3 still define folder ownership. Their goal is no
not "Windows code wrapped without behaviour change".
### Phase 2 build decisions (2026-10-04)
-- **Win32 compat mode is the default** (`FilesWin32Compat=true` in `Directory.Build.props`). Existing Win32/Shell code compiles on Linux against the CsWin32 assembly, which is built automatically for Platform=x64. It cannot run on Linux, so any call that is reached at runtime must be replaced by a `Files.Platform` service. Switching compat mode off was Phase 2's exit criterion; since 2026-10-05 it is Phase 4's (P4-Z), because it depends on removing the legacy storage layer.
+- **Win32 compat mode is off by default since P4-Z (2026-10-05)** (`FilesWin32Compat=false` in `Directory.Build.props`). The desktop build no longer compiles Win32/Shell code or builds the CsWin32 assembly. `-p:FilesWin32Compat=true` still works as an opt-in inventory lens: it compiles the Windows-only folders against CsWin32 and defines `FILES_WIN32_COMPAT`. Nothing compiled that way runs on Linux. Linux CI builds with compat off and fails on any error. (Until P4-Z compat mode was the default. Switching it off was Phase 2's exit criterion and became Phase 4's on 2026-10-05.)
- **Nullable diagnostics are warnings on `-desktop`**, because Uno's annotations differ from WinUI's. Re-enable once the build is clean.
- Stubbed behaviour is tagged `// LINUX-TODO()`. Use `rg -n LINUX-TODO src` to find the backlog.
@@ -247,7 +249,8 @@ Each workstream: define its interface methods from real call sites, implement th
(`Files.Core.Storage`) + the Phase 2 interfaces. `ShellViewModel` first, then properties, search, archives (`Zip*`),
FTP, virtual folders.
- Kept on a single owner (or a strictly serialized queue), because almost everything touches `ShellViewModel`.
-- **Exit:** `Utils/Storage/StorageItems/*` is no longer referenced by the Linux build.
+- **Exit:** `Utils/Storage/StorageItems/*` is no longer referenced by the Linux build. *Superseded by the Revision 3 exit below, which is met. The legacy items
+ (`FtpStorage*`, `SystemStorage*`, `ZipStorageFolder.IsZipPath`, `BaseStorage*`) still compile on Linux behind `LINUX-TODO(storage)` and remain follow-up work.*
#### Phase 4 plan, Revision 3 (2026-10-05): drop the legacy storage layer, then switch Win32 compat off
The two are one project: the legacy items (`Zip*`, `NativeStorageFile`, `VirtualStorageItem`, `Shell*`, `SystemStorageFolder`) call
@@ -287,6 +290,8 @@ Add first, delete last; default build stays green. Contracts (P4-0, then frozen)
| P4-Z | 1β2 | Flip `FilesWin32Compat=false`, fix leftovers, delete `#if WINDOWS` IStorageItem members, CI job blocking | all | strong |
Order: P4-0 β wave 1a (K, A, B, C, H, I) β wave 1b (F, D, G, J) β E β Z. Critical path β 10 working days.
+**Status (2026-10-05):** all 14 packages are merged or in review; P4-E (#73) and P4-Z close the phase. Compat off by default, 0 errors, headless smoke
+test passed (browse, copy/move, trash/restore, browse into a zip, Properties, search, live updates), and the Linux CI compat-off build is blocking.
Gate after every merge: normal desktop build passes; `-p:FilesWin32Compat=false` error count ratchets down vs
`docs/linux-port/compat-off-baseline.txt`; `rg --no-ignore 'BaseStorage|StorageItems\.|Windows\.Win32|PInvoke\.|Win32Helper\.'` on the WP's
files (outside `*.Windows.cs`) β 0. Exit: compat off by default, 0 errors, headless smoke run passes, CI compat-off job blocking.
@@ -356,8 +361,8 @@ files (outside `*.Windows.cs`) β 0. Exit: compat off by default, 0 errors, hea
unit tests, and an `rg --no-ignore` coupling check against the WP's owned files. It reports pass/fail plus a
capped log.
4. **Opus** reviews the diff (correctness, CRLF, `.editorconfig`, AOT rules, no scope creep), then merges into
- `linux/main` in dependency order.
-5. **Haiku** merges `upstream/main` into `linux/main` weekly and reports conflicts. **Opus** resolves non-trivial ones.
+ `main` in dependency order.
+5. **Haiku** merges `upstream/main` into `main` (via a sync PR) weekly and reports conflicts. **Opus** resolves non-trivial ones.
Windows builds can't run locally on Linux. Validate them through GitHub Actions on `windows-2025-vs2026`, with a
Haiku agent polling `gh run` results.
diff --git a/docs/linux-port/compat-off-baseline.txt b/docs/linux-port/compat-off-baseline.txt
index d584a54da734..ec88cc1608cf 100644
--- a/docs/linux-port/compat-off-baseline.txt
+++ b/docs/linux-port/compat-off-baseline.txt
@@ -1,40 +1,2 @@
# Compiler error keys of: dotnet build src/Files.App -f net10.0-desktop -p:FilesWin32Compat=false
# One "file: error CODE: message" per line. Regenerate with scripts/linux/compat-off-check.sh --update.
-src/Files.App/Data/Factories/ShellContextFlyoutHelper.cs: error CS0246: The type or namespace name 'ContextMenu' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Data/Items/ContextMenu.cs: error CS0246: The type or namespace name 'MENU_ITEM_TYPE' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Data/Items/ListedItem.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Data/Items/ShellLinkItem.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Data/Items/WidgetFolderCardItem.cs: error CS0246: The type or namespace name 'IWindowsStorable' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Data/Items/WidgetRecentItem.cs: error CS0246: The type or namespace name 'IShellItem' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Data/Models/RemovableDevice.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?)
-src/Files.App/Data/Models/RemovableDevice.cs: error CS0234: The type or namespace name 'Win32PInvoke' does not exist in the namespace 'Files.App.Helpers' (are you missing an assembly reference?)
-src/Files.App/Helpers/UI/UIFilesystemHelpers.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/UserControls/Toolbar.xaml.cs: error CS0246: The type or namespace name 'OpenWithMenu' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs: error CS0246: The type or namespace name 'Win32PInvoke' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/Helpers/FileTypesHelper.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Helpers/FilesystemResult.cs: error CS0122: 'BOOL' is inaccessible due to its protection level
-src/Files.App/Utils/Storage/Helpers/FolderHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.cs: error CS0234: The type or namespace name 'WinRT' does not exist in the namespace 'Windows.Win32.System' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0103: The name 'SHOW_WINDOW_CMD' does not exist in the current context
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0122: 'HRESULT' is inaccessible due to its protection level
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0122: 'HWND' is inaccessible due to its protection level
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'FILEOPERATION_FLAGS' does not exist in the namespace 'Windows.Win32.UI.Shell' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'PROPERTYKEY' does not exist in the namespace 'Windows.Win32.Foundation' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'SLR_FLAGS' does not exist in the namespace 'Windows.Win32.UI.Shell' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'Disposable' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'ShellFileOperations2' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'ShellItem' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Search/FolderSearch.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?)
-src/Files.App/Utils/Storage/Search/FolderSearch.cs: error CS0246: The type or namespace name 'WIN32_FIND_DATAW' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?)
-src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs: error CS0122: 'IStream' is inaccessible due to its protection level
-src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs: error CS0122: 'STATSTG' is inaccessible due to its protection level
-src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs: error CS0122: 'STGMEDIUM' is inaccessible due to its protection level
-src/Files.App/Utils/Storage/StorageItems/VirtualStorageItem.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?)
-src/Files.App/ViewModels/ShellViewModel.cs: error CS0234: The type or namespace name 'Win32PInvoke' does not exist in the namespace 'Files.App.Helpers' (are you missing an assembly reference?)
-src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?)
-src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs: error CS0234: The type or namespace name 'WinRT' does not exist in the namespace 'Windows.Win32.System' (are you missing an assembly reference?)
diff --git a/docs/linux-port/packaging.md b/docs/linux-port/packaging.md
index 2e487df4a2dc..91ead674b1ff 100644
--- a/docs/linux-port/packaging.md
+++ b/docs/linux-port/packaging.md
@@ -115,6 +115,14 @@ files, which is how the package was build-tested with `makepkg` here (resulting
installed on the dev box, so the package is not linted. `aur/linuxfiles/PKGBUILD` (from source) is unchanged and
unbuilt.
+## Nightly builds
+
+`.github/workflows/nightly-linux.yml` builds the tarball and AppImage on every push to `main` and replaces the
+single rolling pre-release at https://github.com/MemerGamer/LinuxFiles/releases/tag/nightly (stable asset names
+`LinuxFiles-nightly-linux-x64.tar.gz` and `LinuxFiles-nightly-x86_64.AppImage`; the Flatpak is only built for
+`linux-v*` tags). Pull requests targeting `main` get the same files as workflow artifacts with a read-only token.
+Try it: `curl -LO https://github.com/MemerGamer/LinuxFiles/releases/download/nightly/LinuxFiles-nightly-x86_64.AppImage && chmod +x LinuxFiles-nightly-x86_64.AppImage && ./LinuxFiles-nightly-x86_64.AppImage`
+
## Becoming the default file manager (Phase 6, not done yet)
1. `inode/directory`: already in the desktop entry's `MimeType=`. Users (or the installer's post-install hook)
diff --git a/docs/linux-port/phase2-errors.md b/docs/linux-port/phase2-errors.md
index af63bbe759cd..2ae4e5c76a07 100644
--- a/docs/linux-port/phase2-errors.md
+++ b/docs/linux-port/phase2-errors.md
@@ -1,5 +1,10 @@
# Phase 2: Files.App on Uno (net10.0-desktop) build inventory
+> **Status (2026-10-05, P4-Z): resolved.** Win32 compat mode is off by default, and `dotnet build src/Files.App -f net10.0-desktop` reports
+> 0 errors without building CsWin32. The last iterations went 2 β 53 β 2 β 0 errors (declaration errors hid the body errors), plus one more
+> round after `IStorageItemWithPath.Item` became Windows-only (10 β 0). `-p:FilesWin32Compat=true` remains as an opt-in lens and still
+> builds. The inventory below is historical.
+
Branch `linux/p2-head`. Generated from `dotnet build src/Files.App -f net10.0-desktop` (Uno.Sdk 6.7.30, Uno.WinUI 6.7.135).
## How the build is organised
diff --git a/docs/linux-port/threat-model-launching.md b/docs/linux-port/threat-model-launching.md
index d4a6df62e445..8f5e2b31f23c 100644
--- a/docs/linux-port/threat-model-launching.md
+++ b/docs/linux-port/threat-model-launching.md
@@ -19,6 +19,7 @@ Attacker: controls a file's name, content, mode bits and symlinks (downloaded ar
| 9 | Defense in depth inside the launcher: `OpenAsync` refuses executable MIME types (x-executable, x-pie-executable, x-sharedlib, appimage, x-desktop); no xdg-open fallback for files with an execute bit (also via symlink); `LaunchUriAsync` refuses `file:` URIs | `LinuxLauncherService` | `Open_ExecutableMimeTypes...`, `Open_ExecBit...`, `LaunchUri_RefusesFileUris` |
| 10 | More than 5 files opened at once asks first; files needing a gate are processed one by one, never in the bulk default-app launch | `OpenFilesLinuxAsync` | (UI path, covered by 1-9) |
| 11 | Dry-run seam (`FILES_LAUNCH_DRYRUN`) so automated runs spawn nothing | `DryRunProcessStarter` | n/a |
+| 12 | Drop items onto an executable: same plan as gates 1-2 (only confirmable binaries/scripts); the dialog shows the full argv (target plus every dropped path, `DisplaySanitizer.FullArguments`, refused if too large) and exactly that argv is run after the identity re-check | `NavigationHelpers.RunWithItemsLinuxAsync` | `OnlyConfirmedActionsMayRunAFile`, `DisplaySanitizerTests` |
## Review of default-open paths that could execute
diff --git a/src/Files.App.Storage/Archives/ArchiveContext.cs b/src/Files.App.Storage/Archives/ArchiveContext.cs
new file mode 100644
index 000000000000..5dcb0925013c
--- /dev/null
+++ b/src/Files.App.Storage/Archives/ArchiveContext.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Platform.Abstractions.Archives;
+using System.IO;
+using System.Text;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Files.Core.Storage.Contracts;
+using OwlCore.Storage;
+
+namespace Files.App.Storage.Archives
+{
+ internal sealed class ArchiveContext(string path, IArchiveService service, IArchivePasswordPrompt? prompt, System.Collections.Concurrent.ConcurrentDictionary? passwordCache = null)
+ {
+ private string? password = passwordCache is not null && passwordCache.TryGetValue(path, out var cached) ? cached : null;
+ private Dictionary? entries;
+ private readonly SemaphoreSlim gate = new(1, 1);
+ public string Path { get; } = path;
+ public IArchiveService Service { get; } = service;
+
+ public async Task WithPasswordAsync(Func> operation, CancellationToken cancellationToken)
+ {
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ for (var attempt = 0; ; attempt++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var result = await operation(password).ConfigureAwait(false);
+ if (password is not null && passwordCache is not null)
+ passwordCache[Path] = password;
+ return result;
+ }
+ catch (ArchivePasswordException) when (prompt is not null && attempt < 3)
+ {
+ password = await prompt.RequestPasswordAsync(Path, password is not null, cancellationToken).ConfigureAwait(false);
+ if (password is null)
+ throw new OperationCanceledException(cancellationToken);
+ }
+ }
+ }
+ finally { gate.Release(); }
+ }
+
+ public async Task> ListAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (entries is { } cached)
+ return cached;
+ var listing = await WithPasswordAsync(password => Service.ListForBrowsingAsync(Path, password, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false);
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var entry in listing.Entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var name = Normalize(entry.Path);
+ if (name.Length == 0 || entry.LinkTarget is not null)
+ continue;
+ if (!result.TryAdd(name, entry with { Path = name }))
+ throw new ArchiveSecurityException("The archive contains ambiguous entry names.");
+ }
+ foreach (var name in result.Keys.ToArray())
+ {
+ var parent = name;
+ while (parent.LastIndexOf('/') is var index && index >= 0)
+ {
+ parent = parent[..index];
+ if (result.TryGetValue(parent, out var item) && !item.IsDirectory)
+ throw new ArchiveSecurityException("An archive file is also used as a directory.");
+ if (result.Count >= 10000 && !result.ContainsKey(parent))
+ throw new ArchiveSecurityException("The archive hierarchy limit was exceeded.");
+ result.TryAdd(parent, new ArchiveEntryInfo(parent, true, 0, 0, null, false));
+ }
+ }
+ entries = result;
+ return result;
+ }
+
+ internal static string Normalize(string value)
+ {
+ if (Encoding.UTF8.GetByteCount(value) > 4096)
+ throw new ArchiveSecurityException("An archive entry path is too long.");
+ var path = value.Replace('\\', '/');
+ if (path.StartsWith('/') || path.Contains('\0') || (path.Length >= 2 && path[1] == ':'))
+ throw new ArchiveSecurityException("An archive entry has an unsafe path.");
+ var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length > 128 || segments.Any(segment => Encoding.UTF8.GetByteCount(segment) > 255))
+ throw new ArchiveSecurityException("An archive entry path exceeds browsing limits.");
+ if (segments.Any(segment => segment == ".."))
+ throw new ArchiveSecurityException("An archive entry traverses its parent.");
+ return string.Join('/', segments.Where(segment => segment != "."));
+ }
+ }
+}
diff --git a/src/Files.App.Storage/Archives/ArchiveEntryFile.cs b/src/Files.App.Storage/Archives/ArchiveEntryFile.cs
new file mode 100644
index 000000000000..b0be489784f1
--- /dev/null
+++ b/src/Files.App.Storage/Archives/ArchiveEntryFile.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Platform.Abstractions.Archives;
+using Files.Shared.Helpers;
+using System.IO;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Files.Core.Storage.Contracts;
+using OwlCore.Storage;
+
+namespace Files.App.Storage.Archives
+{
+ /// A regular archive member. The caller owns the returned bounded stream.
+ public sealed class ArchiveEntryFile : IChildFile
+ {
+ private readonly ArchiveContext context;
+ private readonly ArchiveFolder parent;
+ public ArchiveEntryInfo Entry { get; }
+ public string Id => context.Path + "/" + Entry.Path;
+ public string Name => ArchiveDisplayName.Escape(Entry.Path[(Entry.Path.LastIndexOf('/') + 1)..]);
+
+ internal ArchiveEntryFile(ArchiveContext context, ArchiveEntryInfo entry, ArchiveFolder parent)
+ {
+ this.context = context;
+ Entry = entry;
+ this.parent = parent;
+ }
+
+ public Task GetParentAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(parent);
+ }
+
+ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ // LINUX-TODO(archives): entry modification remains disabled behind IArchiveService.CanWriteEntries.
+ if (accessMode != FileAccess.Read)
+ throw new NotSupportedException("Archive browsing is read-only.");
+ return context.WithPasswordAsync(password => context.Service.OpenEntryAsync(context.Path, Entry.Path, password, cancellationToken), cancellationToken);
+ }
+ }
+}
diff --git a/src/Files.App.Storage/Archives/ArchiveFolder.cs b/src/Files.App.Storage/Archives/ArchiveFolder.cs
new file mode 100644
index 000000000000..039d599d58e8
--- /dev/null
+++ b/src/Files.App.Storage/Archives/ArchiveFolder.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Platform.Abstractions.Archives;
+using Files.Shared.Helpers;
+using OwlCore.Storage.System.IO;
+using System.IO;
+using System.Runtime.CompilerServices;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Files.Core.Storage.Contracts;
+using OwlCore.Storage;
+
+namespace Files.App.Storage.Archives
+{
+ /// A read-only view of an archive directory, including directories implied by entry names.
+ public sealed class ArchiveFolder : IChildFolder, IGetItem
+ {
+ private readonly ArchiveContext context;
+ internal string EntryPath { get; }
+ public string Id => EntryPath.Length == 0 ? context.Path : context.Path + "/" + EntryPath;
+ public string Name => ArchiveDisplayName.Escape(EntryPath.Length == 0 ? Path.GetFileName(context.Path) : EntryPath[(EntryPath.LastIndexOf('/') + 1)..]);
+
+ internal ArchiveFolder(ArchiveContext context, string entryPath)
+ {
+ this.context = context;
+ EntryPath = entryPath;
+ }
+
+ public Task GetParentAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ IFolder? parent = EntryPath.Length == 0
+ ? new SystemFolder(Path.GetDirectoryName(context.Path)!)
+ : new ArchiveFolder(context, EntryPath.Contains('/') ? EntryPath[..EntryPath.LastIndexOf('/')] : string.Empty);
+ return Task.FromResult(parent);
+ }
+
+ public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var entries = await context.ListAsync(cancellationToken).ConfigureAwait(false);
+ var prefix = EntryPath.Length == 0 ? string.Empty : EntryPath + "/";
+ foreach (var entry in entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!entry.Key.StartsWith(prefix, StringComparison.Ordinal) || entry.Key.Length == prefix.Length || entry.Key.AsSpan(prefix.Length).Contains('/'))
+ continue;
+ if (entry.Value.IsDirectory && type.HasFlag(StorableType.Folder))
+ yield return new ArchiveFolder(context, entry.Key);
+ else if (!entry.Value.IsDirectory && type.HasFlag(StorableType.File))
+ yield return new ArchiveEntryFile(context, entry.Value, this);
+ }
+ }
+
+ public async Task GetItemAsync(string id, CancellationToken cancellationToken = default)
+ {
+ await foreach (var item in GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false))
+ if (item.Id == id)
+ return item;
+ throw new FileNotFoundException("The archive child was not found.");
+ }
+
+ public Task GetFolderWatcherAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromException(new NotSupportedException());
+ }
+ }
+}
diff --git a/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs b/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs
new file mode 100644
index 000000000000..5fd7e21667d8
--- /dev/null
+++ b/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Files.Core.Storage.Contracts;
+using OwlCore.Storage;
+
+namespace Files.App.Storage.Archives
+{
+ public static class ArchiveServiceCollectionExtensions
+ {
+ /// Registers archive routing; the host provides IArchiveService and an optional IArchivePasswordPrompt.
+ public static IServiceCollection AddArchiveStorables(this IServiceCollection services)
+ {
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ return services;
+ }
+ }
+}
diff --git a/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs b/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs
new file mode 100644
index 000000000000..24e9e32f2427
--- /dev/null
+++ b/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Platform.Abstractions.Archives;
+using System.IO;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Files.Core.Storage.Contracts;
+using OwlCore.Storage;
+
+namespace Files.App.Storage.Archives
+{
+ /// Routes archive roots and members before the local file system route.
+ public sealed class ArchiveStorableRoute(IArchiveService service, IArchivePasswordPrompt? passwordPrompt = null) : IStorableRoute
+ {
+ // Session-only; avoids re-prompting on every navigation into an encrypted archive
+ private readonly System.Collections.Concurrent.ConcurrentDictionary passwords = new(StringComparer.Ordinal);
+
+ public int Order => 100;
+
+ public async Task TryGetAsync(string path, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (string.IsNullOrEmpty(path) || path.Contains('\0') || !Path.IsPathFullyQualified(path))
+ return StorableResult.NotMine;
+ try
+ {
+ for (var end = 1; end <= path.Length; end++)
+ {
+ if (end < path.Length && path[end] != Path.DirectorySeparatorChar)
+ continue;
+ var container = path[..end];
+ if (!service.IsArchiveFileName(container))
+ continue;
+ var attributes = File.GetAttributes(container);
+ if (attributes.HasFlag(FileAttributes.Directory))
+ continue;
+ var context = new ArchiveContext(container, service, passwordPrompt, passwords);
+ var entryPath = ArchiveContext.Normalize(path[end..].TrimStart('/'));
+ var listing = await context.ListAsync(cancellationToken).ConfigureAwait(false);
+ if (entryPath.Length == 0)
+ return StorableResult.Success(new ArchiveFolder(context, string.Empty));
+ if (!listing.TryGetValue(entryPath, out var entry))
+ return StorableResult.NotFound;
+ var parentPath = entryPath.Contains('/') ? entryPath[..entryPath.LastIndexOf('/')] : string.Empty;
+ return StorableResult.Success(entry.IsDirectory
+ ? new ArchiveFolder(context, entryPath)
+ : new ArchiveEntryFile(context, entry, new ArchiveFolder(context, parentPath)));
+ }
+ return StorableResult.NotMine;
+ }
+ catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { return StorableResult.NotFound; }
+ catch (UnauthorizedAccessException) { return StorableResult.AccessDenied; }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception) { return StorableResult.Error; }
+ }
+ }
+}
diff --git a/src/Files.App.Storage/Legacy/HomeFolder/HomeFolder.cs b/src/Files.App.Storage/Legacy/HomeFolder/HomeFolder.Windows.cs
similarity index 100%
rename from src/Files.App.Storage/Legacy/HomeFolder/HomeFolder.cs
rename to src/Files.App.Storage/Legacy/HomeFolder/HomeFolder.Windows.cs
diff --git a/src/Files.App.Storage/Legacy/HomeFolder/LinuxHomeFolder.cs b/src/Files.App.Storage/Legacy/HomeFolder/LinuxHomeFolder.cs
new file mode 100644
index 000000000000..1a9efb937cf3
--- /dev/null
+++ b/src/Files.App.Storage/Legacy/HomeFolder/LinuxHomeFolder.cs
@@ -0,0 +1,92 @@
+// Copyright (c) Files Community
+// SPDX-License-Identifier: MPL-2.0
+
+using Files.Platform.Abstractions;
+using Files.Platform.Abstractions.Gvfs;
+using Files.Platform.Abstractions.Recent;
+using Files.Platform.Abstractions.Volumes;
+using OwlCore.Storage.System.IO;
+using System.Runtime.CompilerServices;
+
+namespace Files.App.Storage.Storables
+{
+ public sealed class LinuxHomeFolder : IHomeFolder
+ {
+ private readonly IUserDirectories directories;
+ private readonly IVolumeService volumes;
+ private readonly IRecentFilesStore recentFiles;
+ private readonly INetworkLocationService network;
+
+ public string Id => "Home";
+ public string Name => "Home";
+
+ public LinuxHomeFolder(IUserDirectories directories, IVolumeService volumes, IRecentFilesStore recentFiles, INetworkLocationService network)
+ {
+ this.directories = directories;
+ this.volumes = volumes;
+ this.recentFiles = recentFiles;
+ this.network = network;
+ }
+
+ public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.Folder, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (type.HasFlag(StorableType.Folder))
+ {
+ await foreach (var item in GetQuickAccessFolderAsync(cancellationToken))
+ yield return item;
+ await foreach (var item in GetLogicalDrivesAsync(cancellationToken))
+ yield return item;
+ await foreach (var item in GetNetworkLocationsAsync(cancellationToken))
+ yield return item;
+ }
+ if (type.HasFlag(StorableType.File))
+ {
+ await foreach (var item in GetRecentFilesAsync(cancellationToken))
+ yield return item;
+ }
+ }
+
+ public async IAsyncEnumerable GetQuickAccessFolderAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.CompletedTask;
+ foreach (var path in new[] { directories.Desktop, directories.Downloads, directories.Documents, directories.Pictures, directories.Music, directories.Videos }.Distinct(StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (SystemIO.Directory.Exists(path))
+ yield return new SystemFolder(path);
+ }
+ }
+
+ public async IAsyncEnumerable GetLogicalDrivesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var paths = (await volumes.GetVolumesAsync(cancellationToken).ConfigureAwait(false))
+ .SelectMany(volume => volume.MountPoints).Prepend("/").Distinct(StringComparer.Ordinal);
+ foreach (var path in paths)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return new SystemFolder(path);
+ }
+ }
+
+ public async IAsyncEnumerable GetNetworkLocationsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.CompletedTask;
+ foreach (var mount in network.GetMounts())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return new SystemFolder(mount.Path);
+ }
+ }
+
+ public async IAsyncEnumerable GetRecentFilesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.CompletedTask;
+ foreach (var entry in recentFiles.Read())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (SystemIO.File.Exists(entry.Path))
+ yield return new SystemFile(entry.Path);
+ }
+ }
+ }
+}
diff --git a/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj b/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj
new file mode 100644
index 000000000000..251835dddf62
--- /dev/null
+++ b/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+ $(DesktopTargetFramework)
+ enable
+ enable
+ Debug;Release
+ x86;x64;arm64
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs b/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs
new file mode 100644
index 000000000000..1da43b06fe3d
--- /dev/null
+++ b/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs
@@ -0,0 +1,344 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Windows.Foundation;
+
+namespace Files.App.UnoVirtualization
+{
+ ///
+ /// A virtualizing wrapping grid for on Uno Skia, where is not implemented.
+ /// Only the lines inside the viewport are realized, so large folders stay cheap.
+ ///
+ ///
+ /// Plugs a multi-item line layout into Uno's VirtualizingPanelLayout (the same machinery as ).
+ /// That layout API is internal to Uno.UI, so the project publicizes Uno.UI (Krafs.Publicizer), which also lets the runtime skip access checks.
+ ///
+ public sealed class VirtualizingWrapGrid : Panel, IVirtualizingPanel
+ {
+ private readonly WrapGridLayout _layout;
+
+ public VirtualizingWrapGrid()
+ {
+ _layout = new WrapGridLayout { CacheLength = 4d };
+ _layout.Initialize(this);
+ UpdateLayoutOrientation();
+ }
+
+ public static DependencyProperty OrientationProperty { get; } = DependencyProperty.Register(
+ nameof(Orientation),
+ typeof(Orientation),
+ typeof(VirtualizingWrapGrid),
+ new PropertyMetadata(Orientation.Horizontal, new PropertyChangedCallback((d, _) => ((VirtualizingWrapGrid)d).UpdateLayoutOrientation())));
+
+ ///
+ /// Gets or sets the direction items are laid out in before wrapping (same meaning as WrapPanel.Orientation).
+ ///
+ public Orientation Orientation
+ {
+ get => (Orientation)GetValue(OrientationProperty);
+ set => SetValue(OrientationProperty, value);
+ }
+
+ private Orientation ScrollOrientation =>
+ Orientation == Orientation.Horizontal ? Orientation.Vertical : Orientation.Horizontal;
+
+ public override Orientation? PhysicalOrientation => ScrollOrientation;
+
+ public override bool WantsScrollViewerToObscureAvailableSizeBasedOnScrollBarVisibility(Orientation orientation) =>
+ ScrollOrientation == orientation;
+
+ VirtualizingPanelLayout IVirtualizingPanel.GetLayouter() => _layout;
+
+ public override Size MeasureOverride(Size availableSize)
+ {
+ _layout.Update(Orientation == Orientation.Horizontal ? availableSize.Width : availableSize.Height);
+ return _layout.CorrectExtent(_layout.MeasureOverride(availableSize), availableSize);
+ }
+
+ public override Size ArrangeOverride(Size finalSize) => _layout.CorrectExtent(_layout.ArrangeOverride(finalSize), finalSize);
+
+ ///
+ /// Gets the number of items in a row (a column for the vertical-wrap orientation) at the current size.
+ ///
+ public int ItemsPerRow => _layout.ItemsPerRow;
+
+ ///
+ /// Gets the scroll direction: Vertical when items wrap horizontally into rows, Horizontal when they wrap vertically into columns.
+ ///
+ public Orientation PanelScrollOrientation => ScrollOrientation;
+
+ ///
+ /// Scrolls the minimum distance that shows the whole cell of the item at .
+ ///
+ public void EnsureItemVisible(int index) => _layout.EnsureItemVisible(index);
+
+ private const string SupportedUnoVersionPrefix = "6.7.";
+ private static string? s_unsupportedReason;
+ private static bool s_probed;
+
+ ///
+ /// Checks that the Uno internals this panel builds on are present, so callers can fall back to a plain panel (and log
+ /// ) after a Uno upgrade changes them. The result is computed once.
+ ///
+ public static bool IsSupported(out string? reason)
+ {
+ if (!s_probed)
+ {
+ try
+ {
+ var unoVersion = typeof(Panel).Assembly.GetName().Version?.ToString() ?? string.Empty;
+ var informational = typeof(Panel).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false);
+ var full = informational.Length > 0 ? ((System.Reflection.AssemblyInformationalVersionAttribute)informational[0]).InformationalVersion : unoVersion;
+ if (!full.StartsWith(SupportedUnoVersionPrefix, StringComparison.Ordinal))
+ {
+ s_unsupportedReason = $"Uno.UI {full} is not the version the virtualizing wrap grid was built against ({SupportedUnoVersionPrefix}x)";
+ }
+ else
+ {
+ // Instantiating exercises the internal types and members the layout subclasses
+ _ = ((IVirtualizingPanel)new VirtualizingWrapGrid()).GetLayouter();
+ }
+ }
+ catch (Exception ex) when (ex is TypeLoadException or MissingMemberException or MethodAccessException or TypeInitializationException or BadImageFormatException or InvalidProgramException)
+ {
+ s_unsupportedReason = $"{ex.GetType().Name}: {ex.Message}";
+ }
+
+ s_probed = true;
+ }
+
+ reason = s_unsupportedReason;
+ return s_unsupportedReason is null;
+ }
+
+ public static DependencyProperty ProvisionalCellSizeProperty { get; } = DependencyProperty.Register(
+ nameof(ProvisionalCellSize),
+ typeof(Size),
+ typeof(VirtualizingWrapGrid),
+ new PropertyMetadata(new Size(120, 140)));
+
+ ///
+ /// Gets or sets the cell size assumed until the first container has loaded and reports its real size.
+ ///
+ public Size ProvisionalCellSize
+ {
+ get => (Size)GetValue(ProvisionalCellSizeProperty);
+ set
+ {
+ SetValue(ProvisionalCellSizeProperty, value);
+ _layout.SetProvisionalCell(value);
+ }
+ }
+
+ private void UpdateLayoutOrientation() => _layout.Orientation = ScrollOrientation;
+
+ ///
+ /// Uno's layout works in lines of one item (its scroll-offset to item mapping assumes that). Each item therefore gets a virtual strip
+ /// (cell extent / items per row) along the scroll axis, which keeps every item inside the real band of its row, and
+ /// places the container at its real grid cell.
+ ///
+ private sealed class WrapGridLayout : VirtualizingPanelLayout
+ {
+ private const double MinReliableSize = 8d;
+ private const int MaxConsecutiveResizes = 4;
+
+ private double _cellBreadth = 120d;
+ private double _cellExtent = 140d;
+ private int _itemsPerRow = 1;
+ private bool _cellMeasured;
+ private int _consecutiveResizes;
+
+ public override Orientation ScrollOrientation => Orientation;
+
+ public override int GetItemsPerLine() => 1;
+
+ private double StripExtent => _cellExtent / _itemsPerRow;
+
+ public void SetProvisionalCell(Size size)
+ {
+ if (_cellMeasured || size.Width < MinReliableSize || size.Height < MinReliableSize)
+ return;
+
+ _cellBreadth = ScrollOrientation == Orientation.Vertical ? size.Width : size.Height;
+ _cellExtent = ScrollOrientation == Orientation.Vertical ? size.Height : size.Width;
+ }
+
+ public int ItemsPerRow => _itemsPerRow;
+
+ ///
+ /// Adopts the real cell size once a container has loaded (the item template sizes itself through bindings that only resolve after load)
+ /// and re-derives the number of items per row. When either changed, the realized lines are rebuilt around the item that was
+ /// at the top of the viewport, so a resize deep in a large folder does not realize items from the start.
+ ///
+ public void Update(double availableBreadth)
+ {
+ var anchorItem = GetAnchorItem();
+ var changed = false;
+ if (GetFirstMaterializedLine()?.FirstView is { IsLoaded: true } view)
+ {
+ var breadth = GetBreadth(view.DesiredSize);
+ var extent = GetExtent(view.DesiredSize);
+ if (breadth >= MinReliableSize && extent >= MinReliableSize &&
+ (Math.Abs(breadth - _cellBreadth) > 0.5 || Math.Abs(extent - _cellExtent) > 0.5 || !_cellMeasured))
+ {
+ _cellBreadth = breadth;
+ _cellExtent = extent;
+ _cellMeasured = true;
+ changed = true;
+ }
+ }
+
+ if (!double.IsInfinity(availableBreadth) && !double.IsNaN(availableBreadth))
+ {
+ var perRow = Math.Max(1, (int)(availableBreadth / _cellBreadth));
+ if (perRow != _itemsPerRow)
+ {
+ _itemsPerRow = perRow;
+ changed = true;
+ }
+ }
+
+ if (!changed)
+ {
+ _consecutiveResizes = 0;
+ return;
+ }
+
+ // Guards against a cell size that keeps changing between passes
+ if (++_consecutiveResizes > MaxConsecutiveResizes)
+ return;
+
+ if (anchorItem > 0 && ScrollViewer is not null)
+ Reanchor(anchorItem);
+ else
+ Refresh();
+ }
+
+ private int GetAnchorItem()
+ {
+ if (GetFirstMaterializedLine() is null || ScrollViewer is null || _cellExtent <= 0)
+ return -1;
+
+ var count = ItemsControl?.NumberOfItems ?? 0;
+ var row = (int)(ScrollOffset / _cellExtent);
+ return Math.Clamp(row * _itemsPerRow, 0, Math.Max(0, count - 1));
+ }
+
+ private double? _pendingOffset;
+
+ ///
+ /// Rebuilds the realized lines around the current scroll offset (a plain refresh would realize items from the start up to the
+ /// offset), then moves the scroll offset so the item that was at the top stays there once the new extent is known.
+ ///
+ private void Reanchor(int anchorItem)
+ {
+ _pendingOffset = anchorItem / _itemsPerRow * _cellExtent;
+
+ var count = ItemsControl?.NumberOfItems ?? 0;
+ var currentRow = Math.Min((int)(ScrollOffset / _cellExtent), Math.Max(0, (count - 1) / _itemsPerRow));
+ ClearLines(clearContainer: true);
+ Generator.ClearIdCache();
+ SetDynamicSeed(Uno.UI.IndexPath.FromRowSection(currentRow * _itemsPerRow - 1, 0), currentRow * _cellExtent);
+ UpdateLayout(null, isScroll: true);
+ UpdateCompleted();
+
+ ScheduleApplyPendingOffset(0);
+ }
+
+ private void ScheduleApplyPendingOffset(int attempt)
+ {
+ var dispatcher = OwnerPanel.DispatcherQueue;
+ dispatcher?.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => ApplyPendingOffset(attempt));
+ }
+
+ private void ApplyPendingOffset(int attempt)
+ {
+ if (_pendingOffset is not { } target || ScrollViewer is not { } scroller)
+ return;
+
+ var vertical = ScrollOrientation == Orientation.Vertical;
+ var extent = vertical ? scroller.ExtentHeight : scroller.ExtentWidth;
+ var viewport = vertical ? scroller.ViewportHeight : scroller.ViewportWidth;
+
+ // The new extent arrives with the next layout pass; wait for it so the offset isn't clamped to the old one
+ if (extent < target + viewport * 0.5 && attempt < 8)
+ {
+ ScheduleApplyPendingOffset(attempt + 1);
+ return;
+ }
+
+ _pendingOffset = null;
+ var clamped = Math.Clamp(target, 0, Math.Max(0, extent - viewport));
+ scroller.ChangeView(vertical ? null : clamped, vertical ? clamped : null, null, disableAnimation: true);
+ }
+
+ ///
+ /// Uno sizes the panel from per-item strips, which falls short of the real extent by the empty part of the last row.
+ ///
+ public Size CorrectExtent(Size size, Size available)
+ {
+ var count = ItemsControl?.NumberOfItems ?? 0;
+ if (count == 0)
+ return size;
+
+ var extent = Math.Ceiling(count / (double)_itemsPerRow) * _cellExtent;
+ var scrollsVertically = ScrollOrientation == Orientation.Vertical;
+ var availableExtent = scrollsVertically ? available.Height : available.Width;
+ extent = Math.Max(extent, double.IsInfinity(availableExtent) ? 0 : availableExtent);
+ return scrollsVertically ? new Size(size.Width, extent) : new Size(extent, size.Height);
+ }
+
+ ///
+ /// Scrolls the minimum distance that shows the whole cell of an item; Uno only knows the item's virtual strip.
+ ///
+ public void EnsureItemVisible(int index)
+ {
+ if (ScrollViewer is not { } scroller || index < 0)
+ return;
+
+ var start = index / _itemsPerRow * _cellExtent;
+ var end = start + _cellExtent;
+ var vertical = ScrollOrientation == Orientation.Vertical;
+ var offset = vertical ? scroller.VerticalOffset : scroller.HorizontalOffset;
+ var viewport = vertical ? scroller.ViewportHeight : scroller.ViewportWidth;
+
+ double? target = null;
+ if (start < offset)
+ target = start;
+ else if (end > offset + viewport)
+ target = Math.Max(0, end - viewport);
+
+ if (target is { } value)
+ scroller.ChangeView(vertical ? null : value, vertical ? value : null, null, disableAnimation: true);
+ }
+
+ public override Line CreateLine(GeneratorDirection fillDirection, double extentOffset, double availableBreadth, Uno.UI.IndexPath nextVisibleItem)
+ {
+ var flat = GetFlatItemIndex(nextVisibleItem);
+ var view = Generator.DequeueViewForItem(flat)!;
+ AddView(view, fillDirection, extentOffset, 0);
+
+ // Pin the virtual strip: a container whose template has not sized itself yet must not distort the line positions
+ var strip = StripExtent;
+ var start = fillDirection == GeneratorDirection.Forward ? extentOffset : extentOffset - strip;
+ SetBounds(view, ScrollOrientation == Orientation.Vertical
+ ? new Rect(0, start, _cellBreadth, strip)
+ : new Rect(start, 0, strip, _cellBreadth));
+
+ return new Line(flat, (view, nextVisibleItem));
+ }
+
+ public override Rect GetElementArrangeBounds(int elementIndex, Rect containerBounds, Size windowConstraint, Size finalSize)
+ {
+ var row = elementIndex / _itemsPerRow;
+ var column = elementIndex % _itemsPerRow;
+ return ScrollOrientation == Orientation.Vertical
+ ? new Rect(column * _cellBreadth, row * _cellExtent, _cellBreadth, _cellExtent)
+ : new Rect(row * _cellExtent, column * _cellBreadth, _cellExtent, _cellBreadth);
+ }
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs b/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs
new file mode 100644
index 000000000000..5b844b9364c2
--- /dev/null
+++ b/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Platform.Abstractions.Archives;
+using System.Text;
+
+namespace Files.App.Actions
+{
+ internal sealed class ArchivePasswordPrompt : IArchivePasswordPrompt
+ {
+ public async Task RequestPasswordAsync(string archivePath, bool retry, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var viewModel = new CredentialDialogViewModel { PasswordOnly = true, CanBeAnonymous = false, IsWrongPassword = retry };
+ var dialogService = Ioc.Default.GetRequiredService();
+ var result = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => dialogService.ShowDialogAsync(viewModel));
+ cancellationToken.ThrowIfCancellationRequested();
+ if (result != DialogResult.Primary)
+ return null;
+ if (viewModel.Password is not { } password)
+ return null;
+ using (password)
+ return Encoding.UTF8.GetString(password);
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs b/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs
index 2cae00576d18..74e6863c9d2f 100644
--- a/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs
+++ b/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs
@@ -67,7 +67,11 @@ private bool IsContextPageTypeAdaptedToCommand()
{
return
context.PageType != ContentPageTypes.RecycleBin &&
- context.PageType != ContentPageTypes.ZipFolder &&
+ (context.PageType != ContentPageTypes.ZipFolder
+#if !WINDOWS
+ || Ioc.Default.GetRequiredService().CanWriteEntries
+#endif
+ ) &&
context.PageType != ContentPageTypes.ReleaseNotes &&
context.PageType != ContentPageTypes.Settings &&
context.PageType != ContentPageTypes.None;
diff --git a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs
new file mode 100644
index 000000000000..53f80ccb7299
--- /dev/null
+++ b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs
@@ -0,0 +1,134 @@
+ο»Ώ// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.App.Dialogs;
+using Microsoft.UI.Xaml.Controls;
+using System.Text;
+using Windows.Foundation.Metadata;
+using Windows.Storage;
+
+namespace Files.App.Actions
+{
+ internal abstract class BaseDecompressArchiveAction : BaseUIAction, IAction
+ {
+ protected readonly IContentPageContext context;
+ protected IStorageArchiveService StorageArchiveService { get; } = Ioc.Default.GetRequiredService();
+
+ public abstract string Label { get; }
+
+ public abstract string Description { get; }
+
+ public virtual ActionCategory Category
+ => ActionCategory.Archive;
+
+ public virtual HotKey HotKey
+ => HotKey.None;
+
+ public override bool IsExecutable =>
+ (IsContextPageTypeAdaptedToCommand() &&
+ CanDecompressSelectedItems() ||
+ CanDecompressInsideArchive()) &&
+ UIHelpers.CanShowDialog;
+
+ public BaseDecompressArchiveAction()
+ {
+ context = Ioc.Default.GetRequiredService();
+
+ context.PropertyChanged += Context_PropertyChanged;
+ }
+
+ public abstract Task ExecuteAsync(object? parameter = null);
+
+ protected bool IsContextPageTypeAdaptedToCommand()
+ {
+ return
+ context.PageType != ContentPageTypes.RecycleBin &&
+ context.PageType != ContentPageTypes.ZipFolder &&
+ context.PageType != ContentPageTypes.ReleaseNotes &&
+ context.PageType != ContentPageTypes.Settings &&
+ context.PageType != ContentPageTypes.None;
+ }
+
+ protected async Task DecompressArchiveHereAsync(bool smart = false)
+ {
+ if (context.SelectedItems.Count is 0)
+ return;
+
+ var selectedItems = context.SelectedItems.ToList();
+ var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath ?? string.Empty;
+ BaseStorageFolder? currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath);
+
+ foreach (var selectedItem in selectedItems)
+ {
+ var password = string.Empty;
+ BaseStorageFile? archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!);
+
+ if (archive?.Path is null)
+ return;
+
+ if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)))
+ {
+ DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive)
+ {
+ IsArchiveEncrypted = true,
+ ShowPathSelection = false
+ };
+
+ DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel };
+
+ if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
+ decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
+
+ ContentDialogResult option = await decompressArchiveDialog.TryShowAsync();
+ if (option != ContentDialogResult.Primary)
+ return;
+
+ if (decompressArchiveViewModel.Password is not null)
+ password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password);
+ }
+
+ BaseStorageFolder? destinationFolder = null;
+
+ var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archive.Path, password);
+
+ if (smart && currentFolder is not null && isMultipleItems)
+ {
+ destinationFolder =
+ await FilesystemTasks.WrapNullable(() =>
+ currentFolder.CreateFolderAsync(
+ SystemIO.Path.GetFileNameWithoutExtension(archive.Path),
+ CreationCollisionOption.GenerateUniqueName).AsTask());
+ }
+ else
+ {
+ destinationFolder = currentFolder;
+ }
+
+ // Operate decompress
+ var result = await FilesystemTasks.Wrap(() =>
+ StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password));
+ }
+ }
+
+ protected virtual bool CanDecompressInsideArchive()
+ {
+ return false;
+ }
+
+ protected virtual bool CanDecompressSelectedItems()
+ {
+ return StorageArchiveService.CanDecompress(context.SelectedItems);
+ }
+
+ protected virtual void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(IContentPageContext.SelectedItems):
+ case nameof(IContentPageContext.Folder):
+ OnPropertyChanged(nameof(IsExecutable));
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs
index 53f80ccb7299..9e8af621965c 100644
--- a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs
+++ b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs
@@ -1,11 +1,12 @@
ο»Ώ// Copyright (c) Files Community
// Licensed under the MIT License.
+#if !WINDOWS
using Files.App.Dialogs;
using Microsoft.UI.Xaml.Controls;
using System.Text;
-using Windows.Foundation.Metadata;
-using Windows.Storage;
+
+
namespace Files.App.Actions
{
@@ -56,19 +57,17 @@ protected async Task DecompressArchiveHereAsync(bool smart = false)
var selectedItems = context.SelectedItems.ToList();
var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath ?? string.Empty;
- BaseStorageFolder? currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath);
foreach (var selectedItem in selectedItems)
{
var password = string.Empty;
- BaseStorageFile? archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!);
-
- if (archive?.Path is null)
+ var archivePath = selectedItem.ItemPath;
+ if (string.IsNullOrEmpty(archivePath) || !SystemIO.File.Exists(archivePath))
return;
- if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)))
+ if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath)))
{
- DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive)
+ DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath)
{
IsArchiveEncrypted = true,
ShowPathSelection = false
@@ -76,8 +75,7 @@ protected async Task DecompressArchiveHereAsync(bool smart = false)
DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel };
- if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
- decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
+ decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
ContentDialogResult option = await decompressArchiveDialog.TryShowAsync();
if (option != ContentDialogResult.Primary)
@@ -87,26 +85,15 @@ protected async Task DecompressArchiveHereAsync(bool smart = false)
password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password);
}
- BaseStorageFolder? destinationFolder = null;
-
- var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archive.Path, password);
-
- if (smart && currentFolder is not null && isMultipleItems)
- {
- destinationFolder =
- await FilesystemTasks.WrapNullable(() =>
- currentFolder.CreateFolderAsync(
- SystemIO.Path.GetFileNameWithoutExtension(archive.Path),
- CreationCollisionOption.GenerateUniqueName).AsTask());
- }
- else
- {
- destinationFolder = currentFolder;
- }
+ var destinationFolderPath = currentFolderPath;
+ var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archivePath, password);
+ if (smart && isMultipleItems)
+ destinationFolderPath = SystemIO.Path.Combine(currentFolderPath,
+ Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archivePath));
// Operate decompress
var result = await FilesystemTasks.Wrap(() =>
- StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password));
+ StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolderPath, password));
}
}
@@ -132,3 +119,5 @@ protected virtual void Context_PropertyChanged(object? sender, PropertyChangedEv
}
}
}
+
+#endif
diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs
new file mode 100644
index 000000000000..5a1d4a8f4e68
--- /dev/null
+++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs
@@ -0,0 +1,145 @@
+ο»Ώ// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.App.Dialogs;
+using Files.Shared.Helpers;
+using Microsoft.UI.Xaml.Controls;
+using System.IO;
+using System.Text;
+using Windows.Foundation.Metadata;
+using Windows.Storage;
+
+namespace Files.App.Actions
+{
+ [GeneratedRichCommand]
+ internal sealed partial class DecompressArchiveAction : BaseDecompressArchiveAction
+ {
+ private readonly IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService();
+
+ public override string Label
+ => Strings.ExtractFiles.GetLocalizedResource();
+
+ public override string Description
+ => Strings.DecompressArchiveDescription.GetLocalizedFormatResource(context.SelectedItems.Count);
+
+ public string AccessKey
+ => "E";
+
+ public override HotKey HotKey
+ => new(Keys.E, KeyModifiers.Ctrl);
+
+ public DecompressArchiveAction()
+ {
+ }
+
+ public override async Task ExecuteAsync(object? parameter = null)
+ {
+ if (context.ShellPage is null)
+ return;
+
+ var archivePath = GetArchivePath();
+
+ if (string.IsNullOrEmpty(archivePath))
+ return;
+
+ BaseStorageFile? archive = await StorageHelpers.ToStorageItem(archivePath);
+
+ if (archive?.Path is null)
+ return;
+
+ var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path));
+ var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archive.Path));
+ Encoding? detectedEncoding = null;
+ if (isArchiveEncodingUndetermined)
+ {
+ detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archive.Path));
+ }
+ var password = string.Empty;
+ Encoding? encoding = null;
+
+ DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive)
+ {
+ IsArchiveEncrypted = isArchiveEncrypted,
+ IsArchiveEncodingUndetermined = isArchiveEncodingUndetermined,
+ ShowPathSelection = true,
+ DetectedEncoding = detectedEncoding,
+ };
+ DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel };
+
+ if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
+ decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
+
+ ContentDialogResult option = await decompressArchiveDialog.TryShowAsync();
+ if (option != ContentDialogResult.Primary)
+ return;
+
+ if (isArchiveEncrypted && decompressArchiveViewModel.Password is not null)
+ password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password);
+
+ encoding = decompressArchiveViewModel.SelectedEncoding.Encoding;
+
+ // Check if archive still exists
+ if (!StorageHelpers.Exists(archive.Path))
+ return;
+
+ BaseStorageFolder? destinationFolder = decompressArchiveViewModel.DestinationFolder;
+ string destinationFolderPath = decompressArchiveViewModel.DestinationFolderPath;
+
+ // Save extraction location for future use
+ SaveExtractionLocation(destinationFolderPath);
+
+ if (destinationFolder is null)
+ {
+ BaseStorageFolder? parentFolder = await StorageHelpers.ToStorageItem(Path.GetDirectoryName(archive.Path) ?? string.Empty);
+ if (parentFolder is null)
+ return;
+
+ destinationFolder = await FilesystemTasks.WrapNullable(() => parentFolder.CreateFolderAsync(Path.GetFileName(destinationFolderPath), CreationCollisionOption.GenerateUniqueName).AsTask());
+ }
+
+ // Operate decompress
+ var result = await FilesystemTasks.Wrap(() =>
+ StorageArchiveService.DecompressAsync(archive.Path, destinationFolder?.Path ?? string.Empty, password, encoding));
+
+ if (decompressArchiveViewModel.OpenDestinationFolderOnCompletion)
+ await NavigationHelpers.OpenPath(destinationFolderPath, context.ShellPage, FilesystemItemType.Directory);
+ }
+
+ protected override bool CanDecompressInsideArchive()
+ {
+ return
+ context.PageType == ContentPageTypes.ZipFolder &&
+ !context.HasSelection &&
+ context.Folder is not null &&
+ FileExtensionHelpers.IsZipFile(Path.GetExtension(context.Folder.ItemPath));
+ }
+
+ protected override bool CanDecompressSelectedItems()
+ {
+ return context.SelectedItems.Count == 1 && base.CanDecompressSelectedItems();
+ }
+
+ private string? GetArchivePath()
+ {
+ if (!string.IsNullOrEmpty(context.SelectedItem?.ItemPath))
+ return context.SelectedItem?.ItemPath;
+
+ if (context.PageType == ContentPageTypes.ZipFolder && !context.HasSelection)
+ return context.Folder?.ItemPath;
+
+ return null;
+ }
+
+ private void SaveExtractionLocation(string path)
+ {
+ var previousArchiveExtractionLocations = UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations?.ToList() ?? [];
+ previousArchiveExtractionLocations.Remove(path);
+ previousArchiveExtractionLocations.Insert(0, path);
+
+ if (previousArchiveExtractionLocations.Count > 10)
+ UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations = previousArchiveExtractionLocations.RemoveFrom(11);
+ else
+ UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations = previousArchiveExtractionLocations;
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs
index 5a1d4a8f4e68..31d0fd4b8e1b 100644
--- a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs
+++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs
@@ -1,13 +1,14 @@
ο»Ώ// Copyright (c) Files Community
// Licensed under the MIT License.
+#if !WINDOWS
using Files.App.Dialogs;
using Files.Shared.Helpers;
using Microsoft.UI.Xaml.Controls;
using System.IO;
using System.Text;
-using Windows.Foundation.Metadata;
-using Windows.Storage;
+
+
namespace Files.App.Actions
{
@@ -42,22 +43,20 @@ public override async Task ExecuteAsync(object? parameter = null)
if (string.IsNullOrEmpty(archivePath))
return;
- BaseStorageFile? archive = await StorageHelpers.ToStorageItem(archivePath);
-
- if (archive?.Path is null)
+ if (!File.Exists(archivePath))
return;
- var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path));
- var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archive.Path));
+ var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath));
+ var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archivePath));
Encoding? detectedEncoding = null;
if (isArchiveEncodingUndetermined)
{
- detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archive.Path));
+ detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archivePath));
}
var password = string.Empty;
Encoding? encoding = null;
- DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive)
+ DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath)
{
IsArchiveEncrypted = isArchiveEncrypted,
IsArchiveEncodingUndetermined = isArchiveEncodingUndetermined,
@@ -66,8 +65,7 @@ public override async Task ExecuteAsync(object? parameter = null)
};
DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel };
- if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
- decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
+ decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
ContentDialogResult option = await decompressArchiveDialog.TryShowAsync();
if (option != ContentDialogResult.Primary)
@@ -79,27 +77,18 @@ public override async Task ExecuteAsync(object? parameter = null)
encoding = decompressArchiveViewModel.SelectedEncoding.Encoding;
// Check if archive still exists
- if (!StorageHelpers.Exists(archive.Path))
+ if (!File.Exists(archivePath))
return;
- BaseStorageFolder? destinationFolder = decompressArchiveViewModel.DestinationFolder;
string destinationFolderPath = decompressArchiveViewModel.DestinationFolderPath;
// Save extraction location for future use
SaveExtractionLocation(destinationFolderPath);
- if (destinationFolder is null)
- {
- BaseStorageFolder? parentFolder = await StorageHelpers.ToStorageItem(Path.GetDirectoryName(archive.Path) ?? string.Empty);
- if (parentFolder is null)
- return;
-
- destinationFolder = await FilesystemTasks.WrapNullable(() => parentFolder.CreateFolderAsync(Path.GetFileName(destinationFolderPath), CreationCollisionOption.GenerateUniqueName).AsTask());
- }
// Operate decompress
var result = await FilesystemTasks.Wrap(() =>
- StorageArchiveService.DecompressAsync(archive.Path, destinationFolder?.Path ?? string.Empty, password, encoding));
+ StorageArchiveService.DecompressAsync(archivePath, destinationFolderPath, password, encoding));
if (decompressArchiveViewModel.OpenDestinationFolderOnCompletion)
await NavigationHelpers.OpenPath(destinationFolderPath, context.ShellPage, FilesystemItemType.Directory);
@@ -143,3 +132,5 @@ private void SaveExtractionLocation(string path)
}
}
}
+
+#endif
diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs
new file mode 100644
index 000000000000..a730c4ea548d
--- /dev/null
+++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs
@@ -0,0 +1,111 @@
+ο»Ώ// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.App.Dialogs;
+using Microsoft.UI.Xaml.Controls;
+using System.Text;
+using Windows.Foundation.Metadata;
+using Windows.Storage;
+
+namespace Files.App.Actions
+{
+ [GeneratedRichCommand]
+ internal sealed partial class DecompressArchiveToChildFolderAction : BaseDecompressArchiveAction
+ {
+ public override string Label
+ => ComputeLabel();
+
+ public override string Description
+ => Strings.DecompressArchiveToChildFolderDescription.GetLocalizedFormatResource(context.SelectedItems.Count);
+
+ public string AccessKey
+ => "C";
+
+ public DecompressArchiveToChildFolderAction()
+ {
+ }
+
+ public override async Task ExecuteAsync(object? parameter = null)
+ {
+ if (context.SelectedItems.Count is 0)
+ return;
+
+ foreach (var selectedItem in context.SelectedItems)
+ {
+ var password = string.Empty;
+
+ var archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!);
+ var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath;
+ if (archive?.Path is null)
+ return;
+
+ var currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath ?? string.Empty);
+
+ if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)))
+ {
+ DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive)
+ {
+ IsArchiveEncrypted = true,
+ ShowPathSelection = false
+ };
+ DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel };
+
+ if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
+ decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
+
+ ContentDialogResult option = await decompressArchiveDialog.TryShowAsync();
+ if (option != ContentDialogResult.Primary)
+ return;
+
+ password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password!);
+ }
+
+ BaseStorageFolder? destinationFolder = null;
+ if (currentFolder is not null)
+ {
+ var destinationResult = await FilesystemTasks.WrapNullable(() =>
+ currentFolder.CreateFolderAsync(SystemIO.Path.GetFileNameWithoutExtension(archive.Path), CreationCollisionOption.GenerateUniqueName).AsTask());
+ destinationFolder = destinationResult.Result;
+ }
+
+ // Operate decompress
+ await FilesystemTasks.Wrap(() =>
+ StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password));
+ }
+ }
+
+ protected override void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(IContentPageContext.SelectedItems):
+ case nameof(IContentPageContext.Folder):
+ {
+ if (IsContextPageTypeAdaptedToCommand())
+ {
+ OnPropertyChanged(nameof(Label));
+ OnPropertyChanged(nameof(IsExecutable));
+ }
+
+ break;
+ }
+ }
+ }
+
+ private string ComputeLabel()
+ => ToPlatformSeparator(ComputeLabelCore());
+
+ private static string ToPlatformSeparator(string label)
+ => SystemIO.Path.DirectorySeparatorChar == '\\' ? label : label.Replace('\\', SystemIO.Path.DirectorySeparatorChar);
+
+ private string ComputeLabelCore()
+ {
+ if (context.SelectedItems == null || context.SelectedItems.Count == 0)
+ return string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), string.Empty);
+
+ return context.SelectedItems.Count > 1
+ ? string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), "*")
+ : string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), SystemIO.Path.GetFileNameWithoutExtension(context.SelectedItems.First().Name));
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs
index a730c4ea548d..8036eb252ea0 100644
--- a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs
+++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs
@@ -1,11 +1,12 @@
ο»Ώ// Copyright (c) Files Community
// Licensed under the MIT License.
+#if !WINDOWS
using Files.App.Dialogs;
using Microsoft.UI.Xaml.Controls;
using System.Text;
-using Windows.Foundation.Metadata;
-using Windows.Storage;
+
+
namespace Files.App.Actions
{
@@ -34,24 +35,21 @@ public override async Task ExecuteAsync(object? parameter = null)
{
var password = string.Empty;
- var archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!);
+ var archivePath = selectedItem.ItemPath;
var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath;
- if (archive?.Path is null)
+ if (string.IsNullOrEmpty(archivePath) || !SystemIO.File.Exists(archivePath) || string.IsNullOrEmpty(currentFolderPath))
return;
- var currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath ?? string.Empty);
-
- if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)))
+ if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath)))
{
- DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive)
+ DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath)
{
IsArchiveEncrypted = true,
ShowPathSelection = false
};
DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel };
- if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
- decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
+ decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
ContentDialogResult option = await decompressArchiveDialog.TryShowAsync();
if (option != ContentDialogResult.Primary)
@@ -60,17 +58,12 @@ public override async Task ExecuteAsync(object? parameter = null)
password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password!);
}
- BaseStorageFolder? destinationFolder = null;
- if (currentFolder is not null)
- {
- var destinationResult = await FilesystemTasks.WrapNullable(() =>
- currentFolder.CreateFolderAsync(SystemIO.Path.GetFileNameWithoutExtension(archive.Path), CreationCollisionOption.GenerateUniqueName).AsTask());
- destinationFolder = destinationResult.Result;
- }
+ var destinationFolderPath = SystemIO.Path.Combine(currentFolderPath,
+ Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archivePath));
// Operate decompress
await FilesystemTasks.Wrap(() =>
- StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password));
+ StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolderPath, password));
}
}
@@ -109,3 +102,5 @@ private string ComputeLabelCore()
}
}
}
+
+#endif
diff --git a/src/Files.App/Actions/Content/Install/InstallCertificateAction.Windows.cs b/src/Files.App/Actions/Content/Install/InstallCertificateAction.Windows.cs
new file mode 100644
index 000000000000..d714f5a0e590
--- /dev/null
+++ b/src/Files.App/Actions/Content/Install/InstallCertificateAction.Windows.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Shared.Helpers;
+
+namespace Files.App.Actions
+{
+ internal sealed partial class InstallCertificateAction : ObservableObject, IAction
+ {
+ public async Task ExecuteAsync(object? parameter = null)
+ {
+ await ContextMenu.InvokeVerb("add", context.SelectedItems.Select(x => x.ItemPath).ToArray());
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Install/InstallCertificateAction.cs b/src/Files.App/Actions/Content/Install/InstallCertificateAction.cs
index 8663b0fe432c..7c23850cb87e 100644
--- a/src/Files.App/Actions/Content/Install/InstallCertificateAction.cs
+++ b/src/Files.App/Actions/Content/Install/InstallCertificateAction.cs
@@ -36,10 +36,11 @@ public InstallCertificateAction()
context.PropertyChanged += Context_PropertyChanged;
}
- public async Task ExecuteAsync(object? parameter = null)
- {
- await ContextMenu.InvokeVerb("add", context.SelectedItems.Select(x => x.ItemPath).ToArray());
- }
+#if !WINDOWS
+ // LINUX-TODO(install): this Windows command is hidden on Linux.
+ public Task ExecuteAsync(object? parameter = null)
+ => Task.CompletedTask;
+#endif
private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
diff --git a/src/Files.App/Actions/Content/Install/InstallFontAction.cs b/src/Files.App/Actions/Content/Install/InstallFontAction.cs
index 8b080026b681..36e857ce2965 100644
--- a/src/Files.App/Actions/Content/Install/InstallFontAction.cs
+++ b/src/Files.App/Actions/Content/Install/InstallFontAction.cs
@@ -49,10 +49,11 @@ public async Task ExecuteAsync(object? parameter = null)
var paths = context.SelectedItems.Select(item => item.ItemPath!).ToArray();
var outcome = ReturnResult.Success;
var installed = (long)context.SelectedItems.Count;
- if (OperatingSystem.IsWindows())
- await Win32Helper.InstallFontsAsync(paths, false);
- else
- (outcome, installed) = await InstallForCurrentUserAsync(paths);
+#if WINDOWS
+ await Win32Helper.InstallFontsAsync(paths, false);
+#else
+ (outcome, installed) = await InstallForCurrentUserAsync(paths);
+#endif
StatusCenterViewModel.RemoveItem(banner);
var currentWorkingDirectory = context.ShellPage.GetRequiredShellViewModel().WorkingDirectory!;
diff --git a/src/Files.App/Actions/Content/Install/InstallInfDriverAction.Windows.cs b/src/Files.App/Actions/Content/Install/InstallInfDriverAction.Windows.cs
new file mode 100644
index 000000000000..8337e323691b
--- /dev/null
+++ b/src/Files.App/Actions/Content/Install/InstallInfDriverAction.Windows.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Shared.Helpers;
+
+namespace Files.App.Actions
+{
+ internal sealed partial class InstallInfDriverAction : ObservableObject, IAction
+ {
+ public async Task ExecuteAsync(object? parameter = null)
+ {
+ await Task.WhenAll(context.SelectedItems.Select(selectedItem => Win32Helper.InstallInf(selectedItem.ItemPath)));
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Install/InstallInfDriverAction.cs b/src/Files.App/Actions/Content/Install/InstallInfDriverAction.cs
index e1e98b9dce9c..a21b475ac73c 100644
--- a/src/Files.App/Actions/Content/Install/InstallInfDriverAction.cs
+++ b/src/Files.App/Actions/Content/Install/InstallInfDriverAction.cs
@@ -36,10 +36,11 @@ public InstallInfDriverAction()
context.PropertyChanged += Context_PropertyChanged;
}
- public async Task ExecuteAsync(object? parameter = null)
- {
- await Task.WhenAll(context.SelectedItems.Select(selectedItem => Win32Helper.InstallInf(selectedItem.ItemPath)));
- }
+#if !WINDOWS
+ // LINUX-TODO(install): this Windows command is hidden on Linux.
+ public Task ExecuteAsync(object? parameter = null)
+ => Task.CompletedTask;
+#endif
public void Context_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
diff --git a/src/Files.App/Actions/Content/Run/BaseRunAsAction.Windows.cs b/src/Files.App/Actions/Content/Run/BaseRunAsAction.Windows.cs
new file mode 100644
index 000000000000..04cf7f80f29c
--- /dev/null
+++ b/src/Files.App/Actions/Content/Run/BaseRunAsAction.Windows.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+
+
+namespace Files.App.Actions
+{
+ internal abstract partial class BaseRunAsAction : ObservableObject, IAction
+ {
+ public async Task ExecuteAsync(object? parameter = null)
+ {
+ await ContextMenu.InvokeVerb(_verb, _context.SelectedItem!.ItemPath);
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Run/BaseRunAsAction.cs b/src/Files.App/Actions/Content/Run/BaseRunAsAction.cs
index 9fcab5d96849..70ad7c51d1e8 100644
--- a/src/Files.App/Actions/Content/Run/BaseRunAsAction.cs
+++ b/src/Files.App/Actions/Content/Run/BaseRunAsAction.cs
@@ -3,7 +3,7 @@
namespace Files.App.Actions
{
- internal abstract class BaseRunAsAction : ObservableObject, IAction
+ internal abstract partial class BaseRunAsAction : ObservableObject, IAction
{
private readonly IContentPageContext _context;
@@ -28,10 +28,11 @@ public BaseRunAsAction(string verb)
_context.PropertyChanged += Context_PropertyChanged;
}
- public async Task ExecuteAsync(object? parameter = null)
- {
- await ContextMenu.InvokeVerb(_verb, _context.SelectedItem!.ItemPath);
- }
+#if !WINDOWS
+ // LINUX-TODO(launching): Windows shell elevation verbs are hidden on Linux.
+ public Task ExecuteAsync(object? parameter = null)
+ => Task.CompletedTask;
+#endif
public void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
diff --git a/src/Files.App/Actions/Content/Run/RunWithPowershellAction.Windows.cs b/src/Files.App/Actions/Content/Run/RunWithPowershellAction.Windows.cs
new file mode 100644
index 000000000000..5f27a9a5f12f
--- /dev/null
+++ b/src/Files.App/Actions/Content/Run/RunWithPowershellAction.Windows.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Shared.Helpers;
+
+namespace Files.App.Actions
+{
+ internal sealed partial class RunWithPowershellAction : ObservableObject, IAction
+ {
+ public Task ExecuteAsync(object? parameter = null)
+ {
+ var itemPath = context.ShellPage?.SlimContentPage?.SelectedItem?.ItemPath;
+ return Win32Helper.RunPowershellCommandAsync(
+ $"& {Win32Helper.ToPowerShellStringLiteral(itemPath)}",
+ PowerShellExecutionOptions.None,
+ context.Folder?.ItemPath
+ );
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Content/Run/RunWithPowershellAction.cs b/src/Files.App/Actions/Content/Run/RunWithPowershellAction.cs
index b76e5e2b897d..83e77db69d73 100644
--- a/src/Files.App/Actions/Content/Run/RunWithPowershellAction.cs
+++ b/src/Files.App/Actions/Content/Run/RunWithPowershellAction.cs
@@ -35,15 +35,11 @@ public RunWithPowershellAction()
context.PropertyChanged += Context_PropertyChanged;
}
+#if !WINDOWS
+ // LINUX-TODO(launching): this Windows command is hidden on Linux.
public Task ExecuteAsync(object? parameter = null)
- {
- var itemPath = context.ShellPage?.SlimContentPage?.SelectedItem?.ItemPath;
- return Win32Helper.RunPowershellCommandAsync(
- $"& {Win32Helper.ToPowerShellStringLiteral(itemPath)}",
- PowerShellExecutionOptions.None,
- context.Folder?.ItemPath
- );
- }
+ => Task.CompletedTask;
+#endif
private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
diff --git a/src/Files.App/Actions/Content/Share/ShareItemAction.cs b/src/Files.App/Actions/Content/Share/ShareItemAction.cs
index 9cee9a7bab80..0c42a1327d13 100644
--- a/src/Files.App/Actions/Content/Share/ShareItemAction.cs
+++ b/src/Files.App/Actions/Content/Share/ShareItemAction.cs
@@ -29,6 +29,7 @@ public string AccessKey
=> "H";
public bool IsExecutable =>
+ OperatingSystem.IsWindows() &&
IsContextPageTypeAdaptedToCommand() &&
ShareItemHelpers.IsSupported() &&
context.SelectedItems.Any() &&
@@ -43,6 +44,9 @@ public ShareItemAction()
public Task ExecuteAsync(object? parameter = null)
{
+ if (!IsExecutable)
+ return Task.CompletedTask;
+
return ShareItemHelpers.ShareItemsAsync(context.SelectedItems);
}
diff --git a/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs b/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs
index 15477b11f12f..048aa65ffbb8 100644
--- a/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs
+++ b/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs
@@ -63,6 +63,7 @@ public async Task ExecuteAsync(object? parameter = null)
return;
}
+#if WINDOWS
if (storageItem is SystemStorageFolder or SystemStorageFile)
{
var standardItems = await new[] { storageItem }.ToStandardStorageItemsAsync();
@@ -70,6 +71,7 @@ public async Task ExecuteAsync(object? parameter = null)
storageItem = standardItems.First();
}
+#endif
dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName;
dataPackage.SetStorageItems(new[] { storageItem }, false);
diff --git a/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.Windows.cs b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.Windows.cs
new file mode 100644
index 000000000000..3e4030c2da29
--- /dev/null
+++ b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.Windows.cs
@@ -0,0 +1,83 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Microsoft.UI.Xaml.Controls;
+using Windows.Foundation.Metadata;
+
+namespace Files.App.Actions
+{
+ internal sealed partial class CreateAlternateDataStreamAction : BaseUIAction, IAction
+ {
+ public async Task ExecuteAsync(object? parameter = null)
+ {
+ var nameDialog = DynamicDialogFactory.GetFor_CreateAlternateDataStreamDialog();
+ await nameDialog.TryShowAsync();
+
+ if (nameDialog.DynamicResult != DynamicDialogResult.Primary)
+ return;
+
+ var userInput = nameDialog.ViewModel.AdditionalData as string;
+ await Task.WhenAll(context.SelectedItems.Select(async selectedItem =>
+ {
+ var itemPath = selectedItem.ItemPath!;
+ var isDateOk = Win32Helper.GetFileDateModified(itemPath, out var dateModified);
+ var isReadOnly = Win32Helper.HasFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly);
+
+ // Unset read-only attribute (#7534)
+ if (isReadOnly)
+ Win32Helper.UnsetFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly);
+
+ if (!Win32Helper.WriteStringToFile($"{itemPath}:{userInput}", ""))
+ {
+ var dialog = new ContentDialog
+ {
+ Title = Strings.ErrorCreatingDataStreamTitle.GetLocalizedResource(),
+ Content = Strings.ErrorCreatingDataStreamDescription.GetLocalizedResource(),
+ PrimaryButtonText = "Ok".GetLocalizedResource()
+ };
+
+ if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
+ dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
+
+ await dialog.TryShowAsync();
+ }
+
+ // Restore read-only attribute (#7534)
+ if (isReadOnly)
+ Win32Helper.SetFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly);
+
+ // Restore date modified
+ if (isDateOk)
+ Win32Helper.SetFileDateModified(itemPath, dateModified);
+ }));
+
+ if (context.ShellPage is null)
+ return;
+
+ if (FoldersSettingsService.AreAlternateStreamsVisible)
+ await context.ShellPage.Refresh_Click();
+ else if (ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt)
+ {
+ var dialog = new ContentDialog
+ {
+ Title = Strings.DataStreamsAreHiddenTitle.GetLocalizedResource(),
+ Content = Strings.DataStreamsAreHiddenDescription.GetLocalizedResource(),
+ PrimaryButtonText = Strings.Yes.GetLocalizedResource(),
+ SecondaryButtonText = Strings.DontShowAgain.GetLocalizedResource()
+ };
+
+ if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
+ dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
+
+ var result = await dialog.TryShowAsync();
+ if (result == ContentDialogResult.Primary)
+ {
+ FoldersSettingsService.AreAlternateStreamsVisible = true;
+ await context.ShellPage.Refresh_Click();
+ }
+ else
+ ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt = false;
+ }
+ }
+ }
+}
diff --git a/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs
index d38d572b6326..8221b90df10c 100644
--- a/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs
+++ b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs
@@ -39,77 +39,11 @@ public CreateAlternateDataStreamAction()
context.PropertyChanged += Context_PropertyChanged;
}
- public async Task ExecuteAsync(object? parameter = null)
- {
- var nameDialog = DynamicDialogFactory.GetFor_CreateAlternateDataStreamDialog();
- await nameDialog.TryShowAsync();
-
- if (nameDialog.DynamicResult != DynamicDialogResult.Primary)
- return;
-
- var userInput = nameDialog.ViewModel.AdditionalData as string;
- await Task.WhenAll(context.SelectedItems.Select(async selectedItem =>
- {
- var itemPath = selectedItem.ItemPath!;
- var isDateOk = Win32Helper.GetFileDateModified(itemPath, out var dateModified);
- var isReadOnly = Win32Helper.HasFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly);
-
- // Unset read-only attribute (#7534)
- if (isReadOnly)
- Win32Helper.UnsetFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly);
-
- if (!Win32Helper.WriteStringToFile($"{itemPath}:{userInput}", ""))
- {
- var dialog = new ContentDialog
- {
- Title = Strings.ErrorCreatingDataStreamTitle.GetLocalizedResource(),
- Content = Strings.ErrorCreatingDataStreamDescription.GetLocalizedResource(),
- PrimaryButtonText = "Ok".GetLocalizedResource()
- };
-
- if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
- dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
-
- await dialog.TryShowAsync();
- }
-
- // Restore read-only attribute (#7534)
- if (isReadOnly)
- Win32Helper.SetFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly);
-
- // Restore date modified
- if (isDateOk)
- Win32Helper.SetFileDateModified(itemPath, dateModified);
- }));
-
- if (context.ShellPage is null)
- return;
-
- if (FoldersSettingsService.AreAlternateStreamsVisible)
- await context.ShellPage.Refresh_Click();
- else if (ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt)
- {
- var dialog = new ContentDialog
- {
- Title = Strings.DataStreamsAreHiddenTitle.GetLocalizedResource(),
- Content = Strings.DataStreamsAreHiddenDescription.GetLocalizedResource(),
- PrimaryButtonText = Strings.Yes.GetLocalizedResource(),
- SecondaryButtonText = Strings.DontShowAgain.GetLocalizedResource()
- };
-
- if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
- dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;
-
- var result = await dialog.TryShowAsync();
- if (result == ContentDialogResult.Primary)
- {
- FoldersSettingsService.AreAlternateStreamsVisible = true;
- await context.ShellPage.Refresh_Click();
- }
- else
- ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt = false;
- }
- }
+#if !WINDOWS
+ // LINUX-TODO(streams): this Windows command is hidden on Linux.
+ public Task ExecuteAsync(object? parameter = null)
+ => Task.CompletedTask;
+#endif
private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
diff --git a/src/Files.App/Actions/FileSystem/CreateShortcutAction.cs b/src/Files.App/Actions/FileSystem/CreateShortcutAction.cs
index bd632e1acf2d..97e3fdab0833 100644
--- a/src/Files.App/Actions/FileSystem/CreateShortcutAction.cs
+++ b/src/Files.App/Actions/FileSystem/CreateShortcutAction.cs
@@ -24,6 +24,7 @@ public RichGlyph Glyph
=> new(themedIconStyle: "App.ThemedIcons.URL");
public override bool IsExecutable =>
+ Ioc.Default.GetRequiredService().SupportsShortcutFiles &&
context.HasSelection &&
context.CanCreateItem &&
UIHelpers.CanShowDialog;
@@ -37,6 +38,9 @@ public CreateShortcutAction()
public Task ExecuteAsync(object? parameter = null)
{
+ if (!IsExecutable)
+ return Task.CompletedTask;
+
return UIFilesystemHelpers.CreateShortcutAsync(context.ShellPage, context.SelectedItems);
}
diff --git a/src/Files.App/Actions/FileSystem/CreateShortcutFromDialogAction.cs b/src/Files.App/Actions/FileSystem/CreateShortcutFromDialogAction.cs
index 8b1af64ada0d..e34eddf4ccd4 100644
--- a/src/Files.App/Actions/FileSystem/CreateShortcutFromDialogAction.cs
+++ b/src/Files.App/Actions/FileSystem/CreateShortcutFromDialogAction.cs
@@ -30,6 +30,7 @@ public RichGlyph Glyph
=> new("\uE71B");
public override bool IsExecutable =>
+ Ioc.Default.GetRequiredService().SupportsShortcutFiles &&
context.CanCreateItem &&
UIHelpers.CanShowDialog;
@@ -42,6 +43,9 @@ public CreateShortcutFromDialogAction()
public Task ExecuteAsync(object? parameter = null)
{
+ if (!IsExecutable)
+ return Task.CompletedTask;
+
return UIFilesystemHelpers.CreateShortcutFromDialogAsync(context.ShellPage!);
}
diff --git a/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs b/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs
index 6e57587c0a9a..8e0939f003ae 100644
--- a/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs
+++ b/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs
@@ -58,7 +58,11 @@ await confirmationDialog.TryShowAsync() is ContentDialogResult.Primary)
{
var banner = StatusCenterHelper.AddCard_EmptyRecycleBin(ReturnResult.InProgress);
+#if WINDOWS
bool result = await STATask.Run(StorageTrashBinService.EmptyTrashBin, App.Logger);
+#else
+ bool result = await Task.Run(StorageTrashBinService.EmptyTrashBin);
+#endif
StatusCenterViewModel.RemoveItem(banner);
diff --git a/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs b/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs
index 7cfcc9f47adb..a0c689340b32 100644
--- a/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs
+++ b/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs
@@ -33,6 +33,9 @@ public PasteItemAsShortcutAction()
public Task ExecuteAsync(object? parameter = null)
{
+ if (!IsExecutable)
+ return Task.CompletedTask;
+
if (context.ShellPage is not { } shellPage)
return Task.CompletedTask;
@@ -44,6 +47,7 @@ public Task ExecuteAsync(object? parameter = null)
public bool GetIsExecutable()
{
return
+ Ioc.Default.GetRequiredService().SupportsShortcutFiles &&
App.AppModel.IsPasteEnabled &&
context.PageType != ContentPageTypes.Home &&
context.PageType != ContentPageTypes.RecycleBin &&
diff --git a/src/Files.App/Actions/Open/EditInNotepadAction.Windows.cs b/src/Files.App/Actions/Open/EditInNotepadAction.Windows.cs
new file mode 100644
index 000000000000..08f864806a27
--- /dev/null
+++ b/src/Files.App/Actions/Open/EditInNotepadAction.Windows.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Shared.Helpers;
+
+namespace Files.App.Actions
+{
+ internal sealed partial class EditInNotepadAction : ObservableObject, IAction
+ {
+ public Task ExecuteAsync(object? parameter = null)
+ {
+ return Task.WhenAll(context.SelectedItems.Select(item => Win32Helper.RunPowershellCommandAsync($"& 'notepad.exe' {Win32Helper.ToPowerShellStringLiteral(item.ItemPath)}", PowerShellExecutionOptions.Hidden)));
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Open/EditInNotepadAction.cs b/src/Files.App/Actions/Open/EditInNotepadAction.cs
index b785df60d946..25b5061b4d91 100644
--- a/src/Files.App/Actions/Open/EditInNotepadAction.cs
+++ b/src/Files.App/Actions/Open/EditInNotepadAction.cs
@@ -23,6 +23,7 @@ public RichGlyph Glyph
=> new("\uE70F");
public bool IsExecutable =>
+ OperatingSystem.IsWindows() &&
context.SelectedItems.Any() &&
context.PageType != ContentPageTypes.RecycleBin &&
context.PageType != ContentPageTypes.ZipFolder &&
@@ -35,10 +36,11 @@ public EditInNotepadAction()
context.PropertyChanged += Context_PropertyChanged;
}
+#if !WINDOWS
+ // LINUX-TODO(launching): this Windows command is hidden on Linux.
public Task ExecuteAsync(object? parameter = null)
- {
- return Task.WhenAll(context.SelectedItems.Select(item => Win32Helper.RunPowershellCommandAsync($"& 'notepad.exe' {Win32Helper.ToPowerShellStringLiteral(item.ItemPath)}", PowerShellExecutionOptions.Hidden)));
- }
+ => Task.CompletedTask;
+#endif
private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
diff --git a/src/Files.App/Actions/Open/OpenClassicPropertiesAction.Windows.cs b/src/Files.App/Actions/Open/OpenClassicPropertiesAction.Windows.cs
new file mode 100644
index 000000000000..52f9d1551189
--- /dev/null
+++ b/src/Files.App/Actions/Open/OpenClassicPropertiesAction.Windows.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using System.Runtime.InteropServices;
+using Windows.Win32;
+using Windows.Win32.UI.Shell;
+
+namespace Files.App.Actions
+{
+ internal sealed partial class OpenClassicPropertiesAction : ObservableObject, IAction
+ {
+ public Task ExecuteAsync(object? parameter = null)
+ {
+ if (context.HasSelection && context?.SelectedItem?.ItemPath is not null)
+ ExecuteShellCommand(context.SelectedItem.ItemPath);
+ else if (context?.Folder?.ItemPath is not null)
+ ExecuteShellCommand(context.Folder.ItemPath);
+
+ return Task.CompletedTask;
+ }
+
+ private unsafe void ExecuteShellCommand(string itemPath)
+ {
+ SHELLEXECUTEINFOW info = default;
+ info.cbSize = (uint)Marshal.SizeOf(info);
+ info.nShow = 5; // SW_SHOW
+ info.fMask = 0x0000000C; // SEE_MASK_INVOKEIDLIST
+
+ fixed (char* cVerb = "properties", lpFile = itemPath)
+ {
+ info.lpVerb = cVerb;
+ info.lpFile = lpFile;
+
+ PInvoke.ShellExecuteEx(ref info);
+ }
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Open/OpenClassicPropertiesAction.cs b/src/Files.App/Actions/Open/OpenClassicPropertiesAction.cs
index f2a139a1e7da..f52f3d13bea8 100644
--- a/src/Files.App/Actions/Open/OpenClassicPropertiesAction.cs
+++ b/src/Files.App/Actions/Open/OpenClassicPropertiesAction.cs
@@ -2,8 +2,6 @@
// SPDX-License-Identifier: MPL-2.0
using System.Runtime.InteropServices;
-using Windows.Win32;
-using Windows.Win32.UI.Shell;
namespace Files.App.Actions
{
@@ -43,31 +41,12 @@ public OpenClassicPropertiesAction()
context.PropertyChanged += Context_PropertyChanged;
}
+#if !WINDOWS
+ // LINUX-TODO(properties): the Windows shell properties command is hidden on Linux.
public Task ExecuteAsync(object? parameter = null)
- {
- if (context.HasSelection && context?.SelectedItem?.ItemPath is not null)
- ExecuteShellCommand(context.SelectedItem.ItemPath);
- else if (context?.Folder?.ItemPath is not null)
- ExecuteShellCommand(context.Folder.ItemPath);
-
- return Task.CompletedTask;
- }
-
- private unsafe void ExecuteShellCommand(string itemPath)
- {
- SHELLEXECUTEINFOW info = default;
- info.cbSize = (uint)Marshal.SizeOf(info);
- info.nShow = 5; // SW_SHOW
- info.fMask = 0x0000000C; // SEE_MASK_INVOKEIDLIST
+ => Task.CompletedTask;
+#endif
- fixed (char* cVerb = "properties", lpFile = itemPath)
- {
- info.lpVerb = cVerb;
- info.lpFile = lpFile;
-
- PInvoke.ShellExecuteEx(ref info);
- }
- }
private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
diff --git a/src/Files.App/Actions/Open/OpenInIDEAction.cs b/src/Files.App/Actions/Open/OpenInIDEAction.cs
index 7a548b55f7b6..6a6ba511aa0c 100644
--- a/src/Files.App/Actions/Open/OpenInIDEAction.cs
+++ b/src/Files.App/Actions/Open/OpenInIDEAction.cs
@@ -51,6 +51,7 @@ public async Task ExecuteAsync(object? parameter = null)
return;
}
+#if WINDOWS
var res = await Win32Helper.RunPowershellCommandAsync(
$"& {Win32Helper.ToPowerShellStringLiteral(_devToolsSettingsService.IDEPath)} {Win32Helper.ToPowerShellStringLiteral(workingDirectory)}",
PowerShellExecutionOptions.Hidden
@@ -58,6 +59,7 @@ public async Task ExecuteAsync(object? parameter = null)
if (!res)
await DynamicDialogFactory.ShowFor_IDEErrorDialog(_devToolsSettingsService.IDEName);
+#endif
}
private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
diff --git a/src/Files.App/Actions/Open/OpenRepoInIDEAction.cs b/src/Files.App/Actions/Open/OpenRepoInIDEAction.cs
index d62ebbdbc175..e7653a00be76 100644
--- a/src/Files.App/Actions/Open/OpenRepoInIDEAction.cs
+++ b/src/Files.App/Actions/Open/OpenRepoInIDEAction.cs
@@ -34,6 +34,7 @@ public OpenRepoInIDEAction()
public async Task ExecuteAsync(object? parameter = null)
{
+#if WINDOWS
var res = await Win32Helper.RunPowershellCommandAsync(
$"& {Win32Helper.ToPowerShellStringLiteral(_devToolsSettingsService.IDEPath)} {Win32Helper.ToPowerShellStringLiteral(_context.ShellPage!.InstanceViewModel.GitRepositoryPath)}",
PowerShellExecutionOptions.Hidden
@@ -41,6 +42,16 @@ public async Task ExecuteAsync(object? parameter = null)
if (!res)
await DynamicDialogFactory.ShowFor_IDEErrorDialog(_devToolsSettingsService.IDEName);
+#else
+ var path = _context.ShellPage?.InstanceViewModel.GitRepositoryPath;
+ if (!SystemIO.Path.IsPathRooted(path))
+ return;
+
+ var launched = await Ioc.Default.GetRequiredService()
+ .StartAsync(_devToolsSettingsService.IDEPath, [path]);
+ if (!launched)
+ await DynamicDialogFactory.ShowFor_IDEErrorDialog(_devToolsSettingsService.IDEName);
+#endif
}
private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e)
diff --git a/src/Files.App/Actions/Open/OpenSettingsFileAction.cs b/src/Files.App/Actions/Open/OpenSettingsFileAction.cs
index 629311c80bc7..567fad8b7732 100644
--- a/src/Files.App/Actions/Open/OpenSettingsFileAction.cs
+++ b/src/Files.App/Actions/Open/OpenSettingsFileAction.cs
@@ -32,8 +32,12 @@ public async Task ExecuteAsync(object? parameter = null)
try
{
var settingsJsonFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri($"ms-appdata:///local/{Constants.LocalSettings.SettingsFolderName}/{Constants.LocalSettings.UserSettingsFileName}"));
+#if WINDOWS
if (!await Launcher.LaunchFileAsync(settingsJsonFile))
await ContextMenu.InvokeVerb("open", settingsJsonFile.Path);
+#else
+ await NavigationHelpers.OpenFileLinuxAsync(settingsJsonFile.Path);
+#endif
}
catch (Exception ex)
{
diff --git a/src/Files.App/Actions/Open/OpenTerminalAction.Windows.cs b/src/Files.App/Actions/Open/OpenTerminalAction.Windows.cs
new file mode 100644
index 000000000000..0915dcca46f9
--- /dev/null
+++ b/src/Files.App/Actions/Open/OpenTerminalAction.Windows.cs
@@ -0,0 +1,90 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Microsoft.Win32;
+using Windows.Storage;
+using Windows.Win32;
+using Windows.Win32.System.Com;
+
+namespace Files.App.Actions
+{
+ internal partial class OpenTerminalAction : ObservableObject, IAction
+ {
+ // DelegationTerminal CLSIDs registered by Windows Terminal. When one of these
+ // is the user's default, launching wt.exe gives the user's chosen profile and
+ // supports multi-tab. Source: microsoft/terminal policies/WindowsTerminal.admx.
+ private static readonly Guid[] WindowsTerminalDelegationClsids =
+ [
+ new([0x52, 0xFF, 0x2C, 0xE1, 0x66, 0xA8, 0x77, 0x4C, 0x9A, 0x90, 0xF5, 0x70, 0xA7, 0xAA, 0x2C, 0x6B]), // Windows Terminal (stable)
+ new([0x1F, 0x3F, 0x63, 0x86, 0x54, 0x64, 0xEC, 0x40, 0x89, 0xCE, 0xDA, 0x4E, 0xBA, 0x97, 0x7E, 0xE2]), // Windows Terminal Preview
+ ];
+
+
+ protected virtual ProcessStartInfo? GetProcessStartInfo(string[] paths)
+ {
+ if (paths.Length is 0)
+ return null;
+
+ if (IsWindowsTerminalDefault())
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "wt.exe",
+ UseShellExecute = false,
+ ArgumentList = { "-d", paths[0] }
+ };
+
+ for (int i = 1; i < paths.Length; i++)
+ {
+ startInfo.ArgumentList.Add(";");
+ startInfo.ArgumentList.Add("nt");
+ startInfo.ArgumentList.Add("-d");
+ startInfo.ArgumentList.Add(paths[i]);
+ }
+
+ return startInfo;
+ }
+
+ // Launch cmd.exe when Windows Terminal is not the effective default host.
+ return new()
+ {
+ FileName = "cmd.exe",
+ WorkingDirectory = paths[0],
+ UseShellExecute = true
+ };
+ }
+
+ private static unsafe bool IsWindowsTerminalDefault()
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(@"Console\%%Startup");
+ var consoleClsid = Guid.TryParse(key?.GetValue("DelegationConsole") as string, out var consoleClsidResult) ? consoleClsidResult : Guid.Empty;
+ var terminalClsid = Guid.TryParse(key?.GetValue("DelegationTerminal") as string, out var terminalClsidResult) ? terminalClsidResult : Guid.Empty;
+
+ // Windows treats either missing or zero CLSID as "Let Windows decide".
+ if (consoleClsid == Guid.Empty || terminalClsid == Guid.Empty)
+ {
+ // Windows 11 22H2 introduced Terminal as the automatic default.
+ if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22621))
+ return false;
+
+ // Match conhost's IDefaultTerminalMarker probe on the stable console server.
+ // Source: microsoft/terminal src/server/IoDispatchers.cpp.
+ consoleClsid = new([0x47, 0xA9, 0xAC, 0x2E, 0x5F, 0x7F, 0xFA, 0x4C, 0xBA, 0x87, 0x8F, 0x7F, 0xBE, 0xEF, 0xBE, 0x69]);
+ Guid markerIid = new([0xC0, 0x6B, 0x6E, 0x74, 0x05, 0xAB, 0x38, 0x4E, 0xAB, 0x14, 0x71, 0xE8, 0x67, 0x63, 0x14, 0x1F]);
+ var result = PInvoke.CoCreateInstance(&consoleClsid, null, CLSCTX.CLSCTX_LOCAL_SERVER, &markerIid, out var marker);
+ return result.Succeeded && marker is not null;
+ }
+
+ return consoleClsid != new Guid([0xC0, 0x10, 0x3D, 0xB2, 0x2E, 0xE5, 0x1E, 0x41, 0x9D, 0x5B, 0xC0, 0x9F, 0xDF, 0x70, 0x9C, 0x7D])
+ && WindowsTerminalDelegationClsids.Contains(terminalClsid);
+ }
+ catch
+ {
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/Files.App/Actions/Open/OpenTerminalAction.cs b/src/Files.App/Actions/Open/OpenTerminalAction.cs
index 3321a0659a97..ed64ef6a2804 100644
--- a/src/Files.App/Actions/Open/OpenTerminalAction.cs
+++ b/src/Files.App/Actions/Open/OpenTerminalAction.cs
@@ -1,10 +1,7 @@
ο»Ώ// Copyright (c) Files Community
// Licensed under the MIT License.
-using Microsoft.Win32;
using Windows.Storage;
-using Windows.Win32;
-using Windows.Win32.System.Com;
namespace Files.App.Actions
{
@@ -13,14 +10,7 @@ internal partial class OpenTerminalAction : ObservableObject, IAction
{
private readonly IContentPageContext context;
- // DelegationTerminal CLSIDs registered by Windows Terminal. When one of these
- // is the user's default, launching wt.exe gives the user's chosen profile and
- // supports multi-tab. Source: microsoft/terminal policies/WindowsTerminal.admx.
- private static readonly Guid[] WindowsTerminalDelegationClsids =
- [
- new([0x52, 0xFF, 0x2C, 0xE1, 0x66, 0xA8, 0x77, 0x4C, 0x9A, 0x90, 0xF5, 0x70, 0xA7, 0xAA, 0x2C, 0x6B]), // Windows Terminal (stable)
- new([0x1F, 0x3F, 0x63, 0x86, 0x54, 0x64, 0xEC, 0x40, 0x89, 0xCE, 0xDA, 0x4E, 0xBA, 0x97, 0x7E, 0xE2]), // Windows Terminal Preview
- ];
+
public virtual string Label
#if WINDOWS
@@ -91,72 +81,7 @@ private static async Task OpenLinuxTerminalsAsync(string[] paths)
}
#endif
- protected virtual ProcessStartInfo? GetProcessStartInfo(string[] paths)
- {
- if (paths.Length is 0)
- return null;
- if (IsWindowsTerminalDefault())
- {
- var startInfo = new ProcessStartInfo
- {
- FileName = "wt.exe",
- UseShellExecute = false,
- ArgumentList = { "-d", paths[0] }
- };
-
- for (int i = 1; i < paths.Length; i++)
- {
- startInfo.ArgumentList.Add(";");
- startInfo.ArgumentList.Add("nt");
- startInfo.ArgumentList.Add("-d");
- startInfo.ArgumentList.Add(paths[i]);
- }
-
- return startInfo;
- }
-
- // Launch cmd.exe when Windows Terminal is not the effective default host.
- return new()
- {
- FileName = "cmd.exe",
- WorkingDirectory = paths[0],
- UseShellExecute = true
- };
- }
-
- private static unsafe bool IsWindowsTerminalDefault()
- {
- try
- {
- using var key = Registry.CurrentUser.OpenSubKey(@"Console\%%Startup");
- var consoleClsid = Guid.TryParse(key?.GetValue("DelegationConsole") as string, out var consoleClsidResult) ? consoleClsidResult : Guid.Empty;
- var terminalClsid = Guid.TryParse(key?.GetValue("DelegationTerminal") as string, out var terminalClsidResult) ? terminalClsidResult : Guid.Empty;
-
- // Windows treats either missing or zero CLSID as "Let Windows decide".
- if (consoleClsid == Guid.Empty || terminalClsid == Guid.Empty)
- {
- // Windows 11 22H2 introduced Terminal as the automatic default.
- if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22621))
- return false;
-
- // Match conhost's IDefaultTerminalMarker probe on the stable console server.
- // Source: microsoft/terminal src/server/IoDispatchers.cpp.
- consoleClsid = new([0x47, 0xA9, 0xAC, 0x2E, 0x5F, 0x7F, 0xFA, 0x4C, 0xBA, 0x87, 0x8F, 0x7F, 0xBE, 0xEF, 0xBE, 0x69]);
- Guid markerIid = new([0xC0, 0x6B, 0x6E, 0x74, 0x05, 0xAB, 0x38, 0x4E, 0xAB, 0x14, 0x71, 0xE8, 0x67, 0x63, 0x14, 0x1F]);
- var result = PInvoke.CoCreateInstance(&consoleClsid, null, CLSCTX.CLSCTX_LOCAL_SERVER, &markerIid, out var marker);
- return result.Succeeded && marker is not null;
- }
-
- return consoleClsid != new Guid([0xC0, 0x10, 0x3D, 0xB2, 0x2E, 0xE5, 0x1E, 0x41, 0x9D, 0x5B, 0xC0, 0x9F, 0xDF, 0x70, 0x9C, 0x7D])
- && WindowsTerminalDelegationClsids.Contains(terminalClsid);
- }
- catch
- {
- }
-
- return false;
- }
protected virtual string[] GetPaths()
{
diff --git a/src/Files.App/Actions/Open/OpenTerminalAsAdminAction.cs b/src/Files.App/Actions/Open/OpenTerminalAsAdminAction.cs
index 936a560c8845..4cd69845f982 100644
--- a/src/Files.App/Actions/Open/OpenTerminalAsAdminAction.cs
+++ b/src/Files.App/Actions/Open/OpenTerminalAsAdminAction.cs
@@ -22,6 +22,7 @@ public override HotKey HotKey
public override bool IsExecutable
=> OperatingSystem.IsWindows() && base.IsExecutable;
+#if WINDOWS
protected override ProcessStartInfo? GetProcessStartInfo(string[] paths)
{
var startInfo = base.GetProcessStartInfo(paths);
@@ -33,5 +34,6 @@ public override bool IsExecutable
return startInfo;
}
+#endif
}
}
diff --git a/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs b/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs
index 512d363ae2bf..27415b998e33 100644
--- a/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs
+++ b/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs
@@ -63,6 +63,7 @@ public async Task ExecuteAsync(object? parameter = null)
return;
}
+#if WINDOWS
if (storageItem is SystemStorageFolder or SystemStorageFile)
{
var standardItems = await new[] { storageItem }.ToStandardStorageItemsAsync();
@@ -70,6 +71,7 @@ public async Task ExecuteAsync(object? parameter = null)
storageItem = standardItems.First();
}
+#endif
dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName;
dataPackage.SetStorageItems(new[] { storageItem }, false);
diff --git a/src/Files.App/Actions/Start/PinToStartAction.cs b/src/Files.App/Actions/Start/PinToStartAction.cs
index b138f8aed858..1c2b2b9c2ac8 100644
--- a/src/Files.App/Actions/Start/PinToStartAction.cs
+++ b/src/Files.App/Actions/Start/PinToStartAction.cs
@@ -25,6 +25,7 @@ public ActionCategory Category
=> ActionCategory.Start;
public bool IsExecutable =>
+ OperatingSystem.IsWindows() &&
context.ShellPage is not null;
public PinToStartAction()
@@ -34,6 +35,9 @@ public PinToStartAction()
public async Task ExecuteAsync(object? parameter = null)
{
+ if (!IsExecutable)
+ return;
+
if (context.SelectedItems.Count > 0 && context.ShellPage?.SlimContentPage?.SelectedItems is not null)
{
foreach (ListedItem listedItem in context.ShellPage.SlimContentPage.SelectedItems)
diff --git a/src/Files.App/Actions/Start/UnpinFromStartAction.cs b/src/Files.App/Actions/Start/UnpinFromStartAction.cs
index 7166d920ac35..06d819b860ba 100644
--- a/src/Files.App/Actions/Start/UnpinFromStartAction.cs
+++ b/src/Files.App/Actions/Start/UnpinFromStartAction.cs
@@ -24,6 +24,9 @@ public RichGlyph Glyph
public ActionCategory Category
=> ActionCategory.Start;
+ public bool IsExecutable
+ => OperatingSystem.IsWindows() && context.ShellPage is not null;
+
public UnpinFromStartAction()
{
context = Ioc.Default.GetRequiredService();
@@ -31,6 +34,9 @@ public UnpinFromStartAction()
public async Task ExecuteAsync(object? parameter = null)
{
+ if (!IsExecutable)
+ return;
+
if (context.SelectedItems.Count > 0)
{
var selectedItems = context.ShellPage?.SlimContentPage?.SelectedItems
diff --git a/src/Files.App/Data/Contexts/HomePage/HomePageContext.cs b/src/Files.App/Data/Contexts/HomePage/HomePageContext.cs
index 7968eb02324e..9cab4f66bb4c 100644
--- a/src/Files.App/Data/Contexts/HomePage/HomePageContext.cs
+++ b/src/Files.App/Data/Contexts/HomePage/HomePageContext.cs
@@ -1,4 +1,4 @@
-ο»Ώ// Copyright (c) Files Community
+// Copyright (c) Files Community
// Licensed under the MIT License.
using Microsoft.UI.Xaml.Controls.Primitives;
@@ -12,7 +12,20 @@ public sealed partial class HomePageContext : ObservableObject, IHomePageContext
public bool IsAnyItemRightClicked => rightClickedItem is not null;
- public IHomeFolder HomeFolder { get; } = new HomeFolder();
+ public IHomeFolder HomeFolder { get; } = CreateHomeFolder();
+
+ private static IHomeFolder CreateHomeFolder()
+ {
+#if WINDOWS
+ return new HomeFolder();
+#else
+ return new LinuxHomeFolder(
+ Ioc.Default.GetRequiredService(),
+ Ioc.Default.GetRequiredService(),
+ Ioc.Default.GetRequiredService(),
+ Ioc.Default.GetRequiredService());
+#endif
+ }
private WidgetCardItem? rightClickedItem = null;
public WidgetCardItem? RightClickedItem => rightClickedItem;
diff --git a/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs b/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs
index 5b91b8c85878..b138a0b106f8 100644
--- a/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs
+++ b/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs
@@ -1,4 +1,4 @@
-ο»Ώ// Copyright (c) Files Community
+// Copyright (c) Files Community
// SPDX-License-Identifier: MPL-2.0
namespace Files.App.Data.Factories
diff --git a/src/Files.App/Data/Factories/ShellContextFlyoutHelper.Windows.cs b/src/Files.App/Data/Factories/ShellContextFlyoutHelper.Windows.cs
new file mode 100644
index 000000000000..e369c7a4fa2e
--- /dev/null
+++ b/src/Files.App/Data/Factories/ShellContextFlyoutHelper.Windows.cs
@@ -0,0 +1,257 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using CommunityToolkit.WinUI;
+using Files.App.Helpers.ContextFlyouts;
+using Files.Shared.Helpers;
+using Microsoft.UI.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media.Imaging;
+using System.IO;
+using System.Text;
+using Windows.System;
+using Windows.UI.Core;
+using Windows.Win32;
+using Windows.Win32.UI.WindowsAndMessaging;
+
+namespace Files.App.Helpers
+{
+ public static partial class ShellContextFlyoutFactory
+ {
+ private static (string Label, string AccessKey) ExtractLabelAndAccessKey(string rawLabel)
+ {
+ if (string.IsNullOrEmpty(rawLabel))
+ return (string.Empty, string.Empty);
+
+ string accessKey = string.Empty;
+ var labelBuilder = new StringBuilder(rawLabel.Length);
+
+ for (int i = 0; i < rawLabel.Length; i++)
+ {
+ char current = rawLabel[i];
+ if (current != '&')
+ {
+ labelBuilder.Append(current);
+ continue;
+ }
+
+ if (i + 1 >= rawLabel.Length)
+ {
+ labelBuilder.Append('&');
+ continue;
+ }
+
+ char next = rawLabel[++i];
+ if (next == '&')
+ {
+ labelBuilder.Append('&');
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(accessKey) && !char.IsWhiteSpace(next))
+ accessKey = char.ToUpperInvariant(next).ToString();
+
+ labelBuilder.Append(next);
+ }
+
+ return (labelBuilder.ToString(), accessKey);
+ }
+
+ private static ContextMenuFlyoutItemViewModel CreateShellMenuItem(Win32ContextMenuItem menuFlyoutItem, BitmapImage? image)
+ {
+ var (label, accessKey) = ExtractLabelAndAccessKey(menuFlyoutItem.Label ?? string.Empty);
+ return new ContextMenuFlyoutItemViewModel
+ {
+ Text = label,
+ AccessKey = accessKey,
+ Tag = menuFlyoutItem,
+ BitmapIcon = image,
+ };
+ }
+
+ private static async Task LoadMenuFlyoutItemAsync(
+ List menuItemsListLocal,
+ ContextMenu contextMenu,
+ IEnumerable menuFlyoutItems,
+ CancellationToken cancellationToken,
+ bool showIcons = true,
+ int itemsBeforeOverflow = int.MaxValue)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ return;
+
+ var itemsCount = 0; // Separators do not count for reaching the overflow threshold
+ var menuItems = menuFlyoutItems.TakeWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR || ++itemsCount <= itemsBeforeOverflow).ToList();
+ var overflowItems = menuFlyoutItems.Except(menuItems).ToList();
+
+ if (overflowItems.Any(x => x.Type != MENU_ITEM_TYPE.MFT_SEPARATOR))
+ {
+ var moreItem = menuItemsListLocal.FirstOrDefault(x => x.ID == "ItemOverflow");
+ if (moreItem is null)
+ {
+ var menuLayoutSubItem = new ContextMenuFlyoutItemViewModel()
+ {
+ Text = Strings.ShowMoreOptions.GetLocalizedResource(),
+ Glyph = "\xE712",
+ };
+ await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items
+ ?? throw new InvalidOperationException("The shell overflow menu has not been initialized."), contextMenu, overflowItems, cancellationToken, showIcons);
+ menuItemsListLocal.Insert(0, menuLayoutSubItem);
+ }
+ else
+ {
+ await LoadMenuFlyoutItemAsync(moreItem.Items
+ ?? throw new InvalidOperationException("The shell overflow menu has not been initialized."), contextMenu, overflowItems, cancellationToken, showIcons);
+ }
+ }
+
+ foreach (var menuFlyoutItem in menuItems
+ .SkipWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR) // Remove leading separators
+ .Reverse()
+ .SkipWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR)) // Remove trailing separators
+ {
+ if (cancellationToken.IsCancellationRequested)
+ break;
+
+ // Avoid duplicate separators
+ if ((menuFlyoutItem.Type == MENU_ITEM_TYPE.MFT_SEPARATOR) && (menuItemsListLocal.FirstOrDefault()?.ItemType == ContextMenuFlyoutItemType.Separator))
+ continue;
+
+ BitmapImage? image = null;
+ if (showIcons && menuFlyoutItem.Icon is { Length: > 0 })
+ {
+ image = new BitmapImage();
+ using var ms = new MemoryStream(menuFlyoutItem.Icon);
+ await image.SetSourceAsync(ms.AsRandomAccessStream());
+ }
+
+ if (menuFlyoutItem.Type is MENU_ITEM_TYPE.MFT_SEPARATOR)
+ {
+ var menuLayoutItem = new ContextMenuFlyoutItemViewModel()
+ {
+ ItemType = ContextMenuFlyoutItemType.Separator,
+ Tag = menuFlyoutItem
+ };
+ menuItemsListLocal.Insert(0, menuLayoutItem);
+ }
+ else if (!string.IsNullOrEmpty(menuFlyoutItem.Label) && menuFlyoutItem.SubItems is not null)
+ {
+ if (string.Equals(menuFlyoutItem.Label, Win32Helper.ExtractStringFromDLL("shell32.dll", 30312)))
+ menuFlyoutItem.CommandString = "sendto";
+
+ var menuLayoutSubItem = CreateShellMenuItem(menuFlyoutItem, image);
+ menuLayoutSubItem.Items = [];
+
+ if (menuFlyoutItem.SubItems.Any())
+ {
+ await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items, contextMenu, menuFlyoutItem.SubItems, cancellationToken, showIcons);
+ }
+ else
+ {
+ menuLayoutSubItem.LoadSubMenuAction = async () =>
+ {
+ if (await contextMenu.LoadSubMenu(menuFlyoutItem.SubItems))
+ await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items, contextMenu, menuFlyoutItem.SubItems, cancellationToken, showIcons);
+ };
+ }
+
+ menuItemsListLocal.Insert(0, menuLayoutSubItem);
+ }
+ else if (!string.IsNullOrEmpty(menuFlyoutItem.Label))
+ {
+ var menuLayoutItem = CreateShellMenuItem(menuFlyoutItem, image);
+ menuLayoutItem.Command = new AsyncRelayCommand