Skip to content

Commit cce26be

Browse files
committed
цель-исполняемый: перетаскивание файлов запускает exe/скрипт (open with)
Если цель — исполняемый или скрипт (.exe/.com/.bat/.cmd/.ps1/.py/.pyw/.vbs/ .wsf/.js/.jar), то дроп файлов на её плитку запускает её с этими файлами как аргументами (поведение Windows «open with»), а не копирует. Ярлык .lnk резолвится в настоящую цель (с кешем). Скрипты, которые shell-open открыл бы в редакторе (.ps1/.py/.jar), запускаются через интерпретатор (powershell/py/java). Drag-over показывает бейдж ▶ и эффект-ссылку. Undo нет (это запуск). Тесты xUnit.
1 parent c203ac1 commit cce26be

4 files changed

Lines changed: 121 additions & 0 deletions

File tree

src/Dropwheel/Models/TargetItem.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,20 @@ public class TargetItem
2727
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
2828
public List<SortRule>? Rules { get; set; }
2929

30+
/// <summary>Extensions treated as "drop files to run it with them as arguments" targets.</summary>
31+
public static readonly string[] ExeExtensions =
32+
{ ".exe", ".com", ".bat", ".cmd", ".ps1", ".py", ".pyw", ".vbs", ".wsf", ".js", ".jar" };
33+
34+
public static bool IsExeExtension(string path) =>
35+
ExeExtensions.Contains(System.IO.Path.GetExtension(path).ToLowerInvariant());
36+
3037
[JsonIgnore] public bool IsGroup => Children != null;
3138
[JsonIgnore] public bool IsSorter => SortRules is { Count: > 0 } || Rules is { Count: > 0 };
3239
[JsonIgnore] public bool IsFolder => !IsGroup && Directory.Exists(Path);
40+
41+
/// <summary>An executable or script by its own extension. A .lnk that points at an executable
42+
/// is handled by LaunchService.IsRunTarget, which resolves the shortcut first.</summary>
43+
[JsonIgnore] public bool IsExecutable => !IsGroup && IsExeExtension(Path);
44+
3345
[JsonIgnore] public bool Exists => IsGroup || IsFolder || File.Exists(Path);
3446
}

src/Dropwheel/Services/LaunchService.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
11
using System.Diagnostics;
2+
using System.IO;
23
using Dropwheel.Models;
34

45
namespace Dropwheel.Services;
56

67
public static class LaunchService
78
{
9+
private static readonly Dictionary<string, string> LnkCache = new(StringComparer.OrdinalIgnoreCase);
10+
11+
/// <summary>The path a target really points at: a .lnk is resolved to its target (cached to
12+
/// avoid repeated COM calls during drag-over), anything else is returned unchanged.</summary>
13+
private static string EffectivePath(string path)
14+
{
15+
if (!path.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase)) return path;
16+
if (LnkCache.TryGetValue(path, out var cached)) return cached;
17+
var resolved = ShortcutResolver.Resolve(path);
18+
LnkCache[path] = resolved;
19+
return resolved;
20+
}
21+
22+
/// <summary>Whether dropping files on this target should run it with them as arguments —
23+
/// true for executable/script targets, including a .lnk that points at one.</summary>
24+
public static bool IsRunTarget(TargetItem t) =>
25+
!t.IsGroup && TargetItem.IsExeExtension(EffectivePath(t.Path));
26+
827
public static void Launch(TargetItem t)
928
{
1029
try
@@ -17,6 +36,30 @@ public static void Launch(TargetItem t)
1736
catch { /* target may no longer exist — ignore */ }
1837
}
1938

39+
/// <summary>Quotes each dropped path and joins them for use as command-line arguments.</summary>
40+
public static string BuildArgs(IEnumerable<string> files) =>
41+
string.Join(" ", files.Select(f => $"\"{f}\""));
42+
43+
/// <summary>Runs an executable or script target with the dropped files as arguments — the
44+
/// Windows "open with" behaviour. Scripts that the shell would open in an editor (.ps1/.py/.jar)
45+
/// are launched through their interpreter so they actually run. Returns whether it started.</summary>
46+
public static bool LaunchWith(TargetItem t, IReadOnlyList<string> files)
47+
{
48+
var exe = EffectivePath(t.Path);
49+
var args = BuildArgs(files);
50+
var psi = Path.GetExtension(exe).ToLowerInvariant() switch
51+
{
52+
".ps1" => new ProcessStartInfo("powershell.exe",
53+
$"-NoProfile -ExecutionPolicy Bypass -File \"{exe}\" {args}"),
54+
".py" or ".pyw" => new ProcessStartInfo("py", $"\"{exe}\" {args}"),
55+
".jar" => new ProcessStartInfo("java", $"-jar \"{exe}\" {args}"),
56+
_ => new ProcessStartInfo(exe) { Arguments = args, UseShellExecute = true },
57+
};
58+
psi.WorkingDirectory = Path.GetDirectoryName(exe) ?? "";
59+
try { Process.Start(psi); return true; }
60+
catch { return false; }
61+
}
62+
2063
public static void OpenConfigFolder() =>
2164
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{TargetStore.Dir}\""));
2265
}

src/Dropwheel/UI/OverlayWindow.Dnd.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ private void OnBubbleDragOver(TargetItem t, Border badge, DragEventArgs e)
2222
bool real = e.Data.GetDataPresent(DataFormats.FileDrop);
2323
bool virt = !real && VirtualFileService.HasVirtualFiles(e.Data);
2424
bool text = !real && !virt && TextDropService.HasText(e.Data);
25+
26+
if (real && LaunchService.IsRunTarget(t)) // drop files on an exe/script → run it (open with)
27+
{
28+
e.Effects = DragDropEffects.Link;
29+
((TextBlock)badge.Child).Text = "▶";
30+
badge.Background = Brushes.CornflowerBlue;
31+
badge.Visibility = Visibility.Visible;
32+
e.Handled = true;
33+
return;
34+
}
2535
if ((!real && !virt && !text) || !t.IsFolder)
2636
{ e.Effects = DragDropEffects.None; e.Handled = true; return; }
2737

@@ -45,6 +55,16 @@ private void OnBubbleDrop(TargetItem t, Border badge, DragEventArgs e)
4555
e.Handled = true;
4656
return;
4757
}
58+
if (LaunchService.IsRunTarget(t))
59+
{
60+
bool launched = LaunchService.LaunchWith(t, files);
61+
ShowToast(launched
62+
? $"▶ Opened {files.Length} item(s) with {t.Name}"
63+
: "Could not launch");
64+
CloseCloud();
65+
e.Handled = true;
66+
return;
67+
}
4868
var act = Resolve(t, e);
4969
bool ok = FileOps.Execute(files, t.Path, act);
5070
if (ok) RememberOp(act, files, t.Path);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Dropwheel.Models;
2+
using Dropwheel.Services;
3+
4+
namespace Dropwheel.Tests;
5+
6+
/// <summary>Verifies executable-target detection and the "open with" argument building.</summary>
7+
public sealed class ExecutableTargetTests
8+
{
9+
[Theory]
10+
[InlineData("C:\\tools\\build.exe", true)]
11+
[InlineData("C:\\tools\\run.BAT", true)]
12+
[InlineData("C:\\tools\\task.cmd", true)]
13+
[InlineData("C:\\tools\\legacy.com", true)]
14+
[InlineData("C:\\tools\\script.ps1", true)]
15+
[InlineData("C:\\tools\\tool.py", true)]
16+
[InlineData("C:\\tools\\app.jar", true)]
17+
[InlineData("C:\\tools\\notes.txt", false)]
18+
[InlineData("C:\\tools\\image.png", false)]
19+
[InlineData("C:\\tools\\link.lnk", false)]
20+
[InlineData("C:\\Downloads", false)]
21+
public void IsExecutable_matches_only_executables(string path, bool expected)
22+
{
23+
var t = new TargetItem { Name = "x", Path = path };
24+
Assert.Equal(expected, t.IsExecutable);
25+
}
26+
27+
[Fact]
28+
public void Group_is_never_executable()
29+
{
30+
var t = new TargetItem { Name = "grp", Path = "C:\\x.exe", Children = new() };
31+
Assert.False(t.IsExecutable);
32+
}
33+
34+
[Fact]
35+
public void BuildArgs_quotes_and_joins_paths()
36+
{
37+
var args = LaunchService.BuildArgs(new[] { @"C:\a b\file 1.txt", @"C:\c\file2.txt" });
38+
Assert.Equal("\"C:\\a b\\file 1.txt\" \"C:\\c\\file2.txt\"", args);
39+
}
40+
41+
[Fact]
42+
public void BuildArgs_empty_is_empty()
43+
{
44+
Assert.Equal("", LaunchService.BuildArgs(Array.Empty<string>()));
45+
}
46+
}

0 commit comments

Comments
 (0)