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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 91 additions & 28 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,48 +1,111 @@
# Repository Instructions
# Files Development Guidelines

This repository contains the Files Windows desktop app, a WinUI-based file manager for Windows. The codebase includes the main app, reusable controls, storage layers, Win32/CsWin32 interop, packaging support, background/server components, and UI/interaction tests.
This project is a C#/.NET WinUI 3 desktop app; an alternative to File Explorer.

## Codebase Overview
- Protect context usage. Any command with unknown or potentially large output must be capped. Prefer targeted commands such as `rg`, `Get-Content -TotalCount`, `Select-Object -First`, or focused `git diff -- <path>`; for example, `COMMAND 2>&1 | Select-Object -First 200`. If a line cap is still too noisy, narrow the query instead of dumping full output.
- Always follow `.editorconfig`
- Keep changed text files in CRLF line endings
- Keep comments concise and useful. Do not add comments that restate obvious code.
- Never read entire generated files in `bin` or `obj` unless the generated source is directly needed.
- Prefer targeted search over full file reads.
- Touch only what you must. Clean up only files you created or changed for the task.
- Treat file operations, shell integration, drag/drop, preview handlers, archive actions, settings persistence, and localization as high-risk areas.
- For Win32, COM, Shell, clipboard, hotkey, and file operation interop, prefer `src/Files.App.CsWin32`, `NativeMethods.txt`, and existing wrappers/helpers.
- Avoid ad hoc P/Invoke declarations when CsWin32 or existing interop code can cover the API.
- Do not edit generated CsWin32 output directly. Update source declarations, wrappers, or generator inputs instead.
- For UI work, use existing XAML resources, controls, converters, commands, and localization patterns. Avoid one-off styles or hard-coded user-visible strings.
- Start by identifying the smallest relevant project, feature area, and files for the task.
- Read nearby code before adding new abstractions. Prefer existing WinUI, MVVM, service, command, and storage patterns.
- Keep implementation scoped to the requested behavior. Avoid opportunistic refactors, formatting churn, dependency updates, and generated file edits.
- Treat tool output as evidence. When behavior changes, run the focused build that can prove it and report anything left unverified.

## Codebase Structure

```text
/src
├── Files.App // Main WinUI desktop app: startup, DI, views, view models, actions, services, dialogs, styles, assets, strings, and app helpers.
├── Files.App.CsWin32 // CsWin32 source-generated Win32 interop. Add APIs to NativeMethods.txt here.
├── Files.App.Controls // Reusable WinUI controls shared by the app.
├── Files.App.Storage // App-facing storage abstractions and storage implementation pieces.
├── Files.App.BackgroundTasks // Background task project.
├── Files.App.Server // App service/server behavior.
├── Files.App.Launcher // Launch-related entry points.
├── Files.App.OpenDialog // File open dialog-specific app project/folder.
├── Files.App.SaveDialog // File save dialog-specific app project/folder.
├── Files.App (Package) // Packaging-related app project assets.
├── Files.Core.Storage // Lower-level storage primitives that should not depend on the main WinUI app.
├── Files.Core.SourceGenerator // Roslyn source generators used by the solution.
└── Files.Shared // Shared models, helpers, and code used by multiple projects.
├── Files.App Main WinUI app
├── Files.App.Controls Shared app controls
├── Files.App.Storage App storage abstractions and implementations
├── Files.App.CsWin32 Generated/native Win32 interop project
├── Files.App.BackgroundTasks Background task project
├── Files.App.Server App service/server project
├── Files.Core.SourceGenerator Roslyn source generators and analyzers
├── Files.Core.Storage Core storage abstractions
└── Files.Shared Shared attributes, extensions, and common code
```

```text
/tests
├── Files.App.UITests // UI test assets and views.
├── Files.InteractionTests // Interaction tests used by CI automation.
└── Files.App.UnitTests // Placeholder/stale in this checkout; verify project files before assuming unit tests exist here.
├── Files.App.UITests
├── Files.App.UnitTests
└── Files.InteractionTests
```

## When Dealing With Interop Code
## Build

Prefer explicit platform/configuration builds.
Unless the task is specifically about resolving or inspecting warnings, add `-v:quiet -clp:ErrorsOnly` to `msbuild` commands so the log proves success or shows only actionable errors.

```powershell
msbuild -restore Files.slnx -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly
```

If `msbuild` isn't available in the current shell, run it from Visual Studio Developer PowerShell. Match `-arch`, `-host_arch`, and `-p:Platform` to the platform you're verifying; use `x64` for x64 work and `arm64` for ARM64 work.

```powershell
pwsh.exe -NoProfile -Command "& {
Import-Module 'C:\Program Files\Microsoft Visual Studio\18\Professional\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
Enter-VsDevShell 1ba2cc4e -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -host_arch=x64'
msbuild -restore src/Files.App/Files.App.csproj -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly
}"
```

For focused C# work, build the affected project first.
Do not run build commands in parallel.

```powershell
msbuild -restore src/Files.Shared/Files.Shared.csproj -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly
msbuild -restore src/Files.Core.SourceGenerator/Files.Core.SourceGenerator.csproj -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly
msbuild -restore src/Files.App/Files.App.csproj -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly
```

When the user asks to convert marshaled interop code into unmarshaled interop, or asks to remove trim-unsafe manual P/Invoke definitions, see [docs/interop-unmarshaled-conversion.md](docs/interop-unmarshaled-conversion.md).
## Test

Prefer adding APIs and related generated types to `src/Files.App.CsWin32/NativeMethods.txt`, then update the callees to use CsWin32-generated `Windows.Win32.PInvoke` APIs directly. Do not leave manual `DllImport` definitions in place or replace them with local `LibraryImport` declarations when CsWin32 can generate the API.
We currently don't have a suitable set of tests for AI agents. Just make sure that the builds succeed.

## When Building the App
## Commit & Push

Use `.github/workflows/ci.yml` as the source of truth for building.
For normal local verification, build with MSBuild restore and explicit configuration/platform; packaging is not required.
When asked to commit, run these commands beforehand:

```powershell
msbuild -restore src\Files.App\Files.App.csproj /p:Configuration=Debug /p:Platform=x64
git status --short
git diff --check
```

## When Packaging the App
Do not revert unrelated user changes. Stage only files that belong to the requested change.

Use concise commit messages that describe the behavior change, for example:

```text
Add source-generated settings storage
```

## Open a PR

When asked to open a PR, use a short PR title that names the behavior, not the implementation mechanics only, and prepend the PR type:

- "Fix": use this prefix when the linked issue is a bug
- "Feature": use this prefix when the linked issue is a feature request
- "Code Quality": anything else

The repository maintainers draft release notes based on these PR types: only fixes and feature requests are listed.

Good examples:

```text
Fix: Fixed an issue where thumbnails wouldn't refresh when a file was updated
Feature: Add support for previewing AVI files in the Preview Pane
Code Quality: Add source-generated settings serialization
```

Use `.github/workflows/ci.yml` as the source of truth for packaging. Adjust `Configuration`, `Platform`, and `AppxBundlePlatforms` as needed.
For the PR body, follow `./.github/PULL_REQUEST_TEMPLATE.md`.
1 change: 1 addition & 0 deletions CLAUDE.md
11 changes: 10 additions & 1 deletion src/Files.App.CsWin32/ManualGuid.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Files Community
// Copyright (c) Files Community
// Licensed under the MIT License.

using System;
Expand Down Expand Up @@ -62,6 +62,9 @@ public static Guid* IID_IStorageProviderStatusUISourceFactory

[GuidRVAGen.Guid("000214F4-0000-0000-C000-000000000046")]
public static partial Guid* IID_IContextMenu2 { get; }

[GuidRVAGen.Guid("0000010E-0000-0000-C000-000000000046")]
public static partial Guid* IID_IDataObject { get; }
}

public static unsafe partial class CLSID
Expand Down Expand Up @@ -89,6 +92,9 @@ public static unsafe partial class CLSID

[GuidRVAGen.Guid("D969A300-E7FF-11d0-A93B-00A0C90F2719")]
public static partial Guid* CLSID_NewMenu { get; }

[GuidRVAGen.Guid("09799AFB-AD67-11D1-ABCD-00C04FC30936")]
public static partial Guid* CLSID_OpenWithMenu { get; }
}

public static unsafe partial class BHID
Expand All @@ -98,6 +104,9 @@ public static unsafe partial class BHID

[GuidRVAGen.Guid("94F60519-2850-4924-AA5A-D15E84868039")]
public static partial Guid* BHID_EnumItems { get; }

[GuidRVAGen.Guid("B8C0BD9F-ED24-455C-83E6-D5390C4FE8C4")]
public static partial Guid* BHID_DataObject { get; }
}

public static unsafe partial class FOLDERID
Expand Down
1 change: 1 addition & 0 deletions src/Files.App.CsWin32/NativeMethods.txt
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ S_FALSE
IExecuteCommand
IObjectWithSelection
SHCreateShellItemArrayFromShellItem
IDataObject
IShellExtInit
IContextMenu2
GetSubMenu
Expand Down
27 changes: 26 additions & 1 deletion src/Files.App/Data/Commands/Manager/CommandGroupManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,31 @@ public override string AutomationId
];
}

internal sealed class OpenWithCommandGroup : CommandGroup
{
public override string Name => "OpenWith";

public override string DisplayName
=> Strings.OpenWith.GetLocalizedResource();

public override string Description
=> Strings.OpenItemWithApplicationPickerDescription.GetLocalizedFormatResource(1);

public override RichGlyph Glyph
=> new(themedIconStyle: "App.ThemedIcons.OpenWith");

public override string AccessKey
=> "O";

public override ActionCategory Category
=> ActionCategory.Open;

public override IReadOnlyList<CommandCodes> Commands =>
[
CommandCodes.OpenItemWithApplicationPicker,
];
}

internal sealed class EditTagsCommandGroup : CommandGroup
{
public override string Name => "EditTags";
Expand All @@ -111,4 +136,4 @@ public override ActionCategory Category
// No predefined commands — the flyout contents are built dynamically
// from the user's defined file tags via FileTagsContextMenu.
public override IReadOnlyList<CommandCodes> Commands => [];
}
}
1 change: 0 additions & 1 deletion src/Files.App/Data/Items/ToolbarSections.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ public static class ToolbarDefaultsTemplate
[AlwaysVisibleContextId] =
[
new(commandGroup: nameof(CommandGroups.NewItem), showLabel: true),
new(commandCode: ToolbarItemDescriptor.SeparatorCommandCode, showIcon: false),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there a reason the separator was removed?

new(commandCode: nameof(CommandCodes.CutItem)),
new(commandCode: nameof(CommandCodes.CopyItem)),
new(commandCode: nameof(CommandCodes.PasteItem)),
Expand Down
2 changes: 1 addition & 1 deletion src/Files.App/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"profiles": {
"MsixPackage": {
"Run": {
Comment thread
yair100 marked this conversation as resolved.
"commandName": "MsixPackage"
}
}
Expand Down
86 changes: 81 additions & 5 deletions src/Files.App/UserControls/Toolbar.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using System.IO;
using Windows.Win32.UI.WindowsAndMessaging;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
using FlyoutPlacementMode = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode;
Expand All @@ -24,6 +25,8 @@ public sealed partial class Toolbar : UserControl
private readonly DispatcherQueueTimer toolbarRefreshTimer;
private readonly IContentPageContext PageContext = Ioc.Default.GetRequiredService<IContentPageContext>();
private UserControls.Menus.FileTagsContextMenu? editTagsMenu;
private OpenWithMenu? openWithMenu;
private int openWithFlyoutRequestId;

[GeneratedDependencyProperty]
public partial NavigationToolbarViewModel? ViewModel { get; set; }
Expand Down Expand Up @@ -55,6 +58,7 @@ private void Toolbar_Unloaded(object sender, RoutedEventArgs e)
UserSettingsService.AppearanceSettingsService.PropertyChanged -= AppearanceSettings_PropertyChanged;
if (editTagsMenu is not null)
editTagsMenu.TagsChanged -= EditTagsMenu_TagsChanged;
openWithMenu?.Dispose();
}

partial void OnViewModelChanged(NavigationToolbarViewModel? newValue)
Expand Down Expand Up @@ -206,7 +210,17 @@ and not ContentPageTypes.RecycleBin
: FlyoutPlacementMode.Bottom
};

((MenuFlyout)btn.Flyout).Opening += (s, _) => PopulateGroupFlyout((MenuFlyout)s, group);
((MenuFlyout)btn.Flyout).Opening += async (s, _) =>
{
try
{
await PopulateGroupFlyoutAsync((MenuFlyout)s, group);
}
catch (Exception ex)
{
Debug.WriteLine(ex);
}
};

btn.IsEnabled = group.Commands.Any(c => c is not CommandCodes.None && Commands[c].IsExecutable);
}
Expand Down Expand Up @@ -370,9 +384,16 @@ private void UnpinToolbarItem(string contextId, int index, ToolbarItemSettingsEn
}
}

private void PopulateGroupFlyout(MenuFlyout flyout, CommandGroup group)
private async Task PopulateGroupFlyoutAsync(MenuFlyout flyout, CommandGroup group)
{
flyout.Items.Clear();

if (group is OpenWithCommandGroup)
{
await PopulateOpenWithFlyoutAsync(flyout);
return;
}

foreach (var code in group.Commands)
if (Commands[code] is { Code: not CommandCodes.None } cmd)
flyout.Items.Add(CreateGroupMenuItem(cmd));
Expand All @@ -384,7 +405,7 @@ private void PopulateGroupFlyout(MenuFlyout flyout, CommandGroup group)
var fmt = $"D{entries.Count.ToString().Length}";
for (int i = 0; i < entries.Count; i++)
{
var item = CreateShellNewEntryMenuItem(entries[i]);
var item = await CreateShellNewEntryMenuItemAsync(entries[i]);
item.AccessKey = (i + 1).ToString(fmt);
item.Command = ViewModel!.CreateNewFileCommand;
item.CommandParameter = entries[i];
Expand All @@ -393,13 +414,68 @@ private void PopulateGroupFlyout(MenuFlyout flyout, CommandGroup group)
}
}

private static MenuFlyoutItem CreateShellNewEntryMenuItem(ShellNewEntry entry)
private async Task PopulateOpenWithFlyoutAsync(MenuFlyout flyout)
{
var requestId = ++openWithFlyoutRequestId;

flyout.Items.Add(new MenuFlyoutItem
{
Text = Strings.Loading.GetLocalizedResource(),
IsEnabled = false,
});

openWithMenu?.Dispose();
openWithMenu = null;

OpenWithMenu? loadedOpenWithMenu = null;
if (PageContext.SelectedItems.Count is 1 && PageContext.SelectedItem?.ItemPath is string path)
loadedOpenWithMenu = await OpenWithMenu.GetForFileAsync(path);

if (requestId != openWithFlyoutRequestId)
{
loadedOpenWithMenu?.Dispose();
return;
}

openWithMenu = loadedOpenWithMenu;

flyout.Items.Clear();

if (openWithMenu is not null)
{
foreach (var item in openWithMenu.Items.Where(x => x.Type is MENU_ITEM_TYPE.MFT_STRING && !string.IsNullOrWhiteSpace(x.Label)))
flyout.Items.Add(await CreateOpenWithMenuItemAsync(openWithMenu, item));
}
}

private static async Task<MenuFlyoutItem> CreateOpenWithMenuItemAsync(OpenWithMenu menu, Win32ContextMenuItem entry)
{
MenuFlyoutItem item;
if (entry.Icon is { Length: > 0 })
{
using var ms = new MemoryStream(entry.Icon);
var image = new BitmapImage();
await image.SetSourceAsync(ms.AsRandomAccessStream());
item = new MenuFlyoutItemWithImage { BitmapIcon = image };
Comment thread
0x5bfa marked this conversation as resolved.
}
else
{
item = new MenuFlyoutItem();
}

item.Text = entry.Label;
item.Command = new AsyncRelayCommand(async () => await menu.InvokeItem(entry.ID));

return item;
}

private static async Task<MenuFlyoutItem> CreateShellNewEntryMenuItemAsync(ShellNewEntry entry)
{
if (!string.IsNullOrEmpty(entry.IconBase64))
{
using var ms = new MemoryStream(Convert.FromBase64String(entry.IconBase64));
var image = new BitmapImage();
_ = image.SetSourceAsync(ms.AsRandomAccessStream());
await image.SetSourceAsync(ms.AsRandomAccessStream());
return new MenuFlyoutItemWithImage { Text = entry.Name, BitmapIcon = image };
}
return new MenuFlyoutItem { Text = entry.Name, Icon = new FontIcon { Glyph = "\xE7C3" } };
Expand Down
Loading
Loading