diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ba41b45..16ce5e6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,13 @@ "WebSearch", "Bash(git worktree:*)", "Bash(git merge:*)", - "Bash(git push:*)" + "Bash(git push:*)", + "Bash(gh auth:*)", + "Bash(gh app create:*)", + "Bash(gh extension install:*)", + "Bash(gh extension search:*)", + "WebFetch(domain:docs.claude.com)", + "Bash(powershell.exe:*)" ], "deny": [], "ask": [] @@ -25,5 +31,29 @@ "enableAllProjectMcpServers": true, "enabledMcpjsonServers": [ "playwright" - ] + ], + "hooks": { + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "powershell -ExecutionPolicy Bypass -File \".claude/stop-hook.ps1\"" + } + ] + } + ], + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "notify-send 'Claude Code' 'Awaiting your input'" + } + ] + } + ] + } } \ No newline at end of file diff --git a/.claude/stop-hook.ps1 b/.claude/stop-hook.ps1 new file mode 100644 index 0000000..6152a19 --- /dev/null +++ b/.claude/stop-hook.ps1 @@ -0,0 +1,34 @@ +# Claude Code Stop Hook Script +param() + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +$currentWorkingDir = $env:cwd +if (-not $currentWorkingDir) { + $currentWorkingDir = Get-Location +} + +# Send toast notification +try { + Import-Module BurntToast -ErrorAction SilentlyContinue + if (Get-Module -Name BurntToast) { + $projectName = Split-Path $currentWorkingDir -Leaf + New-BurntToastNotification -Text "Claude Task Completed", $projectName, "Completed at: $timestamp" + } +} +catch { + # Silently ignore notification failures +} + +# Send email notification +try { + $projectName = Split-Path $currentWorkingDir -Leaf + $emailSubject = "Claude Task Completed" + $emailBody = "Project: $projectName`nDirectory: $currentWorkingDir`nCompleted at: $timestamp" + + if (Test-Path 'E:\tool\SendMail\SendMail.exe') { + & 'E:\tool\SendMail\SendMail.exe' $emailSubject $emailBody + } +} +catch { + # Silently ignore email failures +} \ No newline at end of file diff --git a/KingOfTool/MultiFileExample/Calculator.cs b/KingOfTool/MultiFileExample/Calculator.cs new file mode 100644 index 0000000..aab7476 --- /dev/null +++ b/KingOfTool/MultiFileExample/Calculator.cs @@ -0,0 +1,29 @@ +namespace MultiFileExample +{ + public class Calculator + { + public int Add(int a, int b) + { + return a + b; + } + + public int Subtract(int a, int b) + { + return a - b; + } + + public int Multiply(int a, int b) + { + return a * b; + } + + public double Divide(double a, double b) + { + if (b == 0) + { + throw new System.DivideByZeroException("Cannot divide by zero"); + } + return a / b; + } + } +} \ No newline at end of file diff --git a/KingOfTool/MultiFileExample/Person.cs b/KingOfTool/MultiFileExample/Person.cs new file mode 100644 index 0000000..ea7d382 --- /dev/null +++ b/KingOfTool/MultiFileExample/Person.cs @@ -0,0 +1,27 @@ +using System; + +namespace MultiFileExample +{ + public class Person + { + public string Name { get; set; } + public int Age { get; set; } + + public Person(string name, int age) + { + Name = name; + Age = age; + } + + public void Introduce() + { + Console.WriteLine($"你好,我是{Name},今年{Age}岁。"); + } + + public void Birthday() + { + Age++; + Console.WriteLine($"{Name}过生日了!现在{Age}岁了。"); + } + } +} \ No newline at end of file diff --git a/KingOfTool/MultiFileExample/Program.cs b/KingOfTool/MultiFileExample/Program.cs new file mode 100644 index 0000000..f916aaa --- /dev/null +++ b/KingOfTool/MultiFileExample/Program.cs @@ -0,0 +1,32 @@ +using System; + +namespace MultiFileExample +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Multi-File Compilation Example"); + Console.WriteLine("=============================="); + + // 使用 Calculator 类 + var calculator = new Calculator(); + int result = calculator.Add(10, 20); + Console.WriteLine($"10 + 20 = {result}"); + + result = calculator.Multiply(5, 6); + Console.WriteLine($"5 × 6 = {result}"); + + // 使用 Person 类 + var person = new Person("张三", 25); + person.Introduce(); + + // 使用 StringHelper 静态类 + string text = "Hello World"; + string reversed = StringHelper.Reverse(text); + Console.WriteLine($"'{text}' reversed is '{reversed}'"); + + Console.WriteLine("\n多文件编译成功!"); + } + } +} \ No newline at end of file diff --git a/KingOfTool/MultiFileExample/README.md b/KingOfTool/MultiFileExample/README.md new file mode 100644 index 0000000..a1c680a --- /dev/null +++ b/KingOfTool/MultiFileExample/README.md @@ -0,0 +1,52 @@ +# 多文件编译示例 + +这个示例展示了 SharpPad 的多文件编译功能。 + +## 使用方法 + +### 1. 在 SharpPad 中导入这些文件 +将以下文件添加到 SharpPad: +- `Program.cs` - 主程序入口文件 +- `Calculator.cs` - 计算器类 +- `Person.cs` - 人员类 +- `StringHelper.cs` - 字符串辅助工具类 + +### 2. 选择要一起编译的文件 +有两种方式选择文件: + +#### 方式一:手动选择 +- 在文件列表中,每个文件前都有一个复选框 +- 勾选你想要一起编译的文件 +- 通常需要选择所有相关的 `.cs` 文件 + +#### 方式二:快速选择所有 CS 文件 +- 点击文件列表顶部的 "✓CS" 按钮 +- 这会自动选中所有 `.cs` 文件 + +### 3. 运行代码 +- 确保已选择所有需要的文件 +- 点击"运行"按钮 +- SharpPad 会自动: + - 检测包含 Main 方法的入口文件 + - 将所有选中的文件一起编译 + - 执行生成的程序 + +### 4. 清除选择 +- 点击文件列表顶部的 "✗" 按钮可以清除所有选择 + +## 功能特点 + +1. **自动入口检测**:系统会自动找到包含 `Main` 方法的文件作为程序入口 +2. **跨文件引用**:可以在不同文件间引用类和方法 +3. **命名空间支持**:支持使用命名空间组织代码 +4. **向后兼容**:如果没有选择任何文件,将以单文件模式运行当前编辑器中的代码 + +## 示例说明 + +这个示例包含: +- **Program.cs**:主程序,使用其他文件中定义的类 +- **Calculator.cs**:提供基本的数学运算 +- **Person.cs**:定义人员类及其方法 +- **StringHelper.cs**:提供字符串操作的静态方法 + +运行后,你将看到所有类协同工作的输出结果。 \ No newline at end of file diff --git a/KingOfTool/MultiFileExample/StringHelper.cs b/KingOfTool/MultiFileExample/StringHelper.cs new file mode 100644 index 0000000..2b25724 --- /dev/null +++ b/KingOfTool/MultiFileExample/StringHelper.cs @@ -0,0 +1,33 @@ +using System.Linq; + +namespace MultiFileExample +{ + public static class StringHelper + { + public static string Reverse(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + return new string(input.Reverse().ToArray()); + } + + public static bool IsPalindrome(string input) + { + if (string.IsNullOrEmpty(input)) + return false; + + string cleaned = input.Replace(" ", "").ToLower(); + return cleaned == Reverse(cleaned); + } + + public static int CountWords(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return 0; + + return input.Split(new[] { ' ', '\t', '\n', '\r' }, + System.StringSplitOptions.RemoveEmptyEntries).Length; + } + } +} \ No newline at end of file diff --git a/MonacoRoslynCompletionProvider/Api/CodeRunner.cs b/MonacoRoslynCompletionProvider/Api/CodeRunner.cs index b6b311d..9f8fd8c 100644 --- a/MonacoRoslynCompletionProvider/Api/CodeRunner.cs +++ b/MonacoRoslynCompletionProvider/Api/CodeRunner.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Scripting; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Emit; using System; using System.Collections.Generic; using System.IO; @@ -12,6 +13,8 @@ using monacoEditorCSharp.DataHelpers; using System.Runtime.Loader; using System.Threading; +using System.Diagnostics; +using System.IO.Compression; namespace MonacoRoslynCompletionProvider.Api { @@ -48,6 +51,183 @@ public class RunResult public string Error { get; set; } } + public static async Task RunMultiFileCodeAsync( + List files, + string nuget, + int languageVersion, + Func onOutput, + Func onError, + string sessionId = null) + { + var result = new RunResult(); + CustomAssemblyLoadContext loadContext = null; + Assembly assembly = null; + try + { + var nugetAssemblies = DownloadNugetPackages.LoadPackages(nuget); + loadContext = new CustomAssemblyLoadContext(nugetAssemblies); + + var parseOptions = new CSharpParseOptions( + languageVersion: (LanguageVersion)languageVersion, + kind: SourceCodeKind.Regular, + documentationMode: DocumentationMode.Parse + ); + + string assemblyName = "DynamicCode"; + + // Parse all files into syntax trees + var syntaxTrees = new List(); + foreach (var file in files) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + file.Content, + parseOptions, + path: file.FileName + ); + syntaxTrees.Add(syntaxTree); + } + + // Collect references + var references = new List(); + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (!asm.IsDynamic && !string.IsNullOrEmpty(asm.Location)) + references.Add(MetadataReference.CreateFromFile(asm.Location)); + } + foreach (var pkg in nugetAssemblies) + { + references.Add(MetadataReference.CreateFromFile(pkg.Path)); + } + + var compilation = CSharpCompilation.Create( + assemblyName, + syntaxTrees, + references, + new CSharpCompilationOptions(OutputKind.ConsoleApplication) + ); + + using (var peStream = new MemoryStream()) + { + var compileResult = compilation.Emit(peStream); + if (!compileResult.Success) + { + foreach (var diag in compileResult.Diagnostics) + { + if (diag.Severity == DiagnosticSeverity.Error) + { + var location = diag.Location.GetLineSpan(); + var fileName = location.Path ?? "unknown"; + var line = location.StartLinePosition.Line + 1; + await onError($"[{fileName}:{line}] {diag.GetMessage()}").ConfigureAwait(false); + } + } + result.Error = "Compilation error"; + return result; + } + peStream.Seek(0, SeekOrigin.Begin); + + lock (LoadContextLock) + { + assembly = loadContext.LoadFromStream(peStream); + } + + var entryPoint = assembly.EntryPoint; + if (entryPoint != null) + { + var parameters = entryPoint.GetParameters(); + + async void WriteAction(string text) => await onOutput(text).ConfigureAwait(false); + await using var outputWriter = new ImmediateCallbackTextWriter(WriteAction); + + async void ErrorAction(string text) => await onError(text).ConfigureAwait(false); + await using var errorWriter = new ImmediateCallbackTextWriter(ErrorAction); + + var interactiveReader = new InteractiveTextReader(async prompt => + { + await onOutput($"[INPUT REQUIRED] Please provide input: ").ConfigureAwait(false); + }); + + if (!string.IsNullOrEmpty(sessionId)) + { + lock (_activeReaders) + { + _activeReaders[sessionId] = interactiveReader; + } + } + + var executionTask = Task.Run(async () => + { + TextWriter originalOut = null, originalError = null; + TextReader originalIn = null; + lock (ConsoleLock) + { + originalOut = Console.Out; + originalError = Console.Error; + originalIn = Console.In; + Console.SetOut(outputWriter); + Console.SetError(errorWriter); + Console.SetIn(interactiveReader); + } + try + { + if (parameters.Length == 1 && parameters[0].ParameterType == typeof(string[])) + { + entryPoint.Invoke(null, new object[] { new string[] { "sharpPad" } }); + } + else + { + entryPoint.Invoke(null, null); + } + } + catch (Exception ex) + { + var errorMessage = "Execution error: " + (ex.InnerException?.Message ?? ex.Message); + await onError(errorMessage).ConfigureAwait(false); + } + finally + { + lock (ConsoleLock) + { + Console.SetOut(originalOut); + Console.SetError(originalError); + Console.SetIn(originalIn); + } + + if (!string.IsNullOrEmpty(sessionId)) + { + lock (_activeReaders) + { + _activeReaders.Remove(sessionId); + } + } + interactiveReader?.Dispose(); + } + }); + await executionTask.ConfigureAwait(false); + } + else + { + await onError("No entry point found in the code. Please ensure one file contains a Main method.").ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + await onError("Runtime error: " + ex.Message).ConfigureAwait(false); + } + finally + { + assembly = null; + loadContext?.Unload(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + result.Output = string.Empty; + result.Error = string.Empty; + return result; + } + public static async Task RunProgramCodeAsync( string code, string nuget, @@ -227,27 +407,27 @@ public static async Task RunProgramCodeAsync( // 自定义可卸载的AssemblyLoadContext - private class CustomAssemblyLoadContext : AssemblyLoadContext - { - private readonly Dictionary _packageAssemblies; - - public CustomAssemblyLoadContext(IEnumerable packageAssemblies) : base(isCollectible: true) - { - _packageAssemblies = packageAssemblies? - .GroupBy(p => p.AssemblyName.Name ?? Path.GetFileNameWithoutExtension(p.Path), StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.OrderByDescending(p => p.AssemblyName.Version ?? new Version(0, 0, 0, 0)).First(), StringComparer.OrdinalIgnoreCase) - ?? new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - protected override Assembly Load(AssemblyName assemblyName) - { - if (assemblyName?.Name != null && _packageAssemblies.TryGetValue(assemblyName.Name, out var package)) - { - return LoadFromAssemblyPath(package.Path); - } - - return null; - } + private class CustomAssemblyLoadContext : AssemblyLoadContext + { + private readonly Dictionary _packageAssemblies; + + public CustomAssemblyLoadContext(IEnumerable packageAssemblies) : base(isCollectible: true) + { + _packageAssemblies = packageAssemblies? + .GroupBy(p => p.AssemblyName.Name ?? Path.GetFileNameWithoutExtension(p.Path), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderByDescending(p => p.AssemblyName.Version ?? new Version(0, 0, 0, 0)).First(), StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + protected override Assembly Load(AssemblyName assemblyName) + { + if (assemblyName?.Name != null && _packageAssemblies.TryGetValue(assemblyName.Name, out var package)) + { + return LoadFromAssemblyPath(package.Path); + } + + return null; + } } private class ImmediateCallbackTextWriter : TextWriter @@ -309,5 +489,201 @@ public static void DownloadPackage(string nuget) { DownloadNugetPackages.DownloadAllPackages(nuget); } + + public static async Task BuildMultiFileExecutableAsync( + List files, + string nuget, + int languageVersion, + string outputFileName) + { + // Use SDK publish so all runtime dependencies are present + return await BuildWithDotnetPublishAsync(files, nuget, languageVersion, outputFileName); + } + + public static async Task BuildExecutableAsync( + string code, + string nuget, + int languageVersion, + string outputFileName) + { + var files = new List { new FileContent { FileName = "Program.cs", Content = code } }; + return await BuildWithDotnetPublishAsync(files, nuget, languageVersion, outputFileName); + } + + private static async Task BuildWithDotnetPublishAsync( + List files, + string nuget, + int languageVersion, + string outputFileName) + { + var result = new ExeBuildResult(); + + try + { + var workingRoot = Path.Combine(Path.GetTempPath(), "SharpPadBuilds", Guid.NewGuid().ToString("N")); + var srcDir = Path.Combine(workingRoot, "src"); + var publishDir = Path.Combine(workingRoot, "publish"); + Directory.CreateDirectory(srcDir); + Directory.CreateDirectory(publishDir); + + var outName = string.IsNullOrWhiteSpace(outputFileName) ? "Program.exe" : outputFileName; + var asmName = Path.GetFileNameWithoutExtension(outName); + var artifactFileName = Path.ChangeExtension(outName, ".zip"); + + var tfm = "net9.0"; + var csprojPath = Path.Combine(srcDir, $"{asmName}.csproj"); + + var pkgRefs = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(nuget)) + { + foreach (var part in nuget.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var items = part.Split(',', StringSplitOptions.RemoveEmptyEntries); + var id = items.Length > 0 ? items[0].Trim() : null; + var ver = items.Length > 1 ? items[1].Trim() : null; + if (!string.IsNullOrWhiteSpace(id)) + { + if (!string.IsNullOrWhiteSpace(ver)) + pkgRefs.AppendLine($" "); + else + pkgRefs.AppendLine($" "); + } + } + } + + var langVer = "latest"; + try { langVer = ((LanguageVersion)languageVersion).ToString(); } catch { } + + var csproj = $@" + + Exe + {tfm} + enable + enable + {asmName} + {asmName} + {langVer} + + +{pkgRefs} + +"; + await File.WriteAllTextAsync(csprojPath, csproj, Encoding.UTF8); + + foreach (var f in files) + { + var safeName = string.IsNullOrWhiteSpace(f?.FileName) ? "Program.cs" : f.FileName; + foreach (var c in Path.GetInvalidFileNameChars()) safeName = safeName.Replace(c, '_'); + var dest = Path.Combine(srcDir, safeName); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + await File.WriteAllTextAsync(dest, f?.Content ?? string.Empty, Encoding.UTF8); + } + + static async Task<(int code, string stdout, string stderr)> RunAsync(string fileName, string args, string workingDir) + { + var psi = new ProcessStartInfo(fileName, args) + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + var p = new Process { StartInfo = psi }; + var sbOut = new StringBuilder(); + var sbErr = new StringBuilder(); + p.OutputDataReceived += (_, e) => { if (e.Data != null) sbOut.AppendLine(e.Data); }; + p.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); }; + p.Start(); + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + await p.WaitForExitAsync(); + return (p.ExitCode, sbOut.ToString(), sbErr.ToString()); + } + + var (rc1, o1, e1) = await RunAsync("dotnet", "restore", srcDir); + if (rc1 != 0) + { + var msg = $"dotnet restore failed.\n{o1}\n{e1}"; + result.Success = false; + result.Error = msg; + return result; + } + + var rid = OperatingSystem.IsWindows() ? "win-x64" : OperatingSystem.IsMacOS() ? "osx-x64" : "linux-x64"; + var publishArgs = $"publish -c Release -r {rid} --self-contained true -o \"{publishDir}\""; + var (rc2, o2, e2) = await RunAsync("dotnet", publishArgs, srcDir); + if (rc2 != 0) + { + var msg = $"dotnet publish failed.\n{o2}\n{e2}"; + result.Success = false; + result.Error = msg; + return result; + } + + // Find the actual executable file + string exePath; + if (OperatingSystem.IsWindows()) + { + var defaultExe = Path.Combine(publishDir, asmName + ".exe"); + var requestedExe = Path.Combine(publishDir, outName); + if (File.Exists(defaultExe) && !defaultExe.Equals(requestedExe, StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(requestedExe)) File.Delete(requestedExe); + File.Move(defaultExe, requestedExe); + exePath = requestedExe; + } + else + { + exePath = File.Exists(requestedExe) ? requestedExe : defaultExe; + } + } + else + { + // On Linux/macOS, executable doesn't have .exe extension + exePath = Path.Combine(publishDir, asmName); + if (!File.Exists(exePath)) + { + // Look for any executable file in the publish directory + var executableFiles = Directory.GetFiles(publishDir).Where(f => + { + var info = new FileInfo(f); + return info.Name == asmName || info.Name.StartsWith(asmName); + }); + exePath = executableFiles.FirstOrDefault() ?? exePath; + } + } + + if (!File.Exists(exePath)) + { + result.Success = false; + result.Error = $"Executable file not found at: {exePath}"; + return result; + } + + var artifactPath = Path.Combine(workingRoot, artifactFileName); + if (File.Exists(artifactPath)) + { + File.Delete(artifactPath); + } + + ZipFile.CreateFromDirectory(publishDir, artifactPath, CompressionLevel.Optimal, includeBaseDirectory: false); + + result.Success = true; + result.ExeFilePath = artifactPath; + result.FileSizeBytes = new FileInfo(artifactPath).Length; + result.CompilationMessages.Add($"Built package: {Path.GetFileName(artifactPath)}"); + } + catch (Exception ex) + { + result.Success = false; + result.Error = $"Build error: {ex.Message}"; + } + + return result; + } + } } + + diff --git a/MonacoRoslynCompletionProvider/Api/ExeBuildRequest.cs b/MonacoRoslynCompletionProvider/Api/ExeBuildRequest.cs new file mode 100644 index 0000000..3cc22e7 --- /dev/null +++ b/MonacoRoslynCompletionProvider/Api/ExeBuildRequest.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MonacoRoslynCompletionProvider.Api +{ + public class ExeBuildRequest + { + public List Files { get; set; } + public List Packages { get; set; } + public int LanguageVersion { get; set; } + public string OutputFileName { get; set; } = "Program.exe"; + + // For backward compatibility with single file projects + public string SourceCode { get; set; } + + public bool IsMultiFile => Files != null && Files.Count > 0; + } + + public class ExeBuildResult + { + public bool Success { get; set; } + public string Error { get; set; } + public string ExeFilePath { get; set; } + public long FileSizeBytes { get; set; } + public List CompilationMessages { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/MonacoRoslynCompletionProvider/Api/MultiFileCodeCheckRequest.cs b/MonacoRoslynCompletionProvider/Api/MultiFileCodeCheckRequest.cs new file mode 100644 index 0000000..8c4b860 --- /dev/null +++ b/MonacoRoslynCompletionProvider/Api/MultiFileCodeCheckRequest.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace MonacoRoslynCompletionProvider.Api +{ + public class MultiFileCodeCheckRequest : IRequest + { + public MultiFileCodeCheckRequest() + { + Files = new List(); + Packages = new List(); + } + + /// + /// List of files to check + /// + public List Files { get; set; } + + /// + /// NuGet packages to include + /// + public List Packages { get; set; } + + /// + /// The specific file ID/name whose diagnostics should be mapped + /// back to original offsets for the active editor file. + /// + public string TargetFileId { get; set; } + + /// + /// For backward compatibility with single file requests + /// + public string Code { get; set; } + + /// + /// Whether this is a multi-file request + /// + public bool IsMultiFile => Files != null && Files.Count > 0; + } +} diff --git a/MonacoRoslynCompletionProvider/Api/MultiFileCodeRunRequest.cs b/MonacoRoslynCompletionProvider/Api/MultiFileCodeRunRequest.cs new file mode 100644 index 0000000..be40dac --- /dev/null +++ b/MonacoRoslynCompletionProvider/Api/MultiFileCodeRunRequest.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MonacoRoslynCompletionProvider.Api +{ + public class MultiFileCodeRunRequest + { + public List Files { get; set; } + public List Packages { get; set; } + public int LanguageVersion { get; set; } + public string SessionId { get; set; } + + // For backward compatibility + public string SourceCode { get; set; } + + public bool IsMultiFile => Files != null && Files.Count > 0; + } + + public class FileContent + { + public string FileName { get; set; } + public string Content { get; set; } + public bool IsEntry { get; set; } // Marks if this file contains the Main method + } +} \ No newline at end of file diff --git a/MonacoRoslynCompletionProvider/Api/MultiFileHoverInfoRequest.cs b/MonacoRoslynCompletionProvider/Api/MultiFileHoverInfoRequest.cs new file mode 100644 index 0000000..10a0c0f --- /dev/null +++ b/MonacoRoslynCompletionProvider/Api/MultiFileHoverInfoRequest.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace MonacoRoslynCompletionProvider.Api +{ + public class MultiFileHoverInfoRequest : IRequest + { + public MultiFileHoverInfoRequest() + { + Files = new List(); + Packages = new List(); + } + + /// + /// List of files for hover context + /// + public List Files { get; set; } + + /// + /// The specific file ID where hover is requested + /// + public string TargetFileId { get; set; } + + /// + /// Position in the target file for hover info + /// + public int Position { get; set; } + + /// + /// NuGet packages to include + /// + public List Packages { get; set; } + + /// + /// For backward compatibility with single file requests + /// + public string Code { get; set; } + + /// + /// Whether this is a multi-file request + /// + public bool IsMultiFile => Files != null && Files.Count > 0; + } +} \ No newline at end of file diff --git a/MonacoRoslynCompletionProvider/Api/MultiFileSignatureHelpRequest.cs b/MonacoRoslynCompletionProvider/Api/MultiFileSignatureHelpRequest.cs new file mode 100644 index 0000000..3c4d27c --- /dev/null +++ b/MonacoRoslynCompletionProvider/Api/MultiFileSignatureHelpRequest.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace MonacoRoslynCompletionProvider.Api +{ + public class MultiFileSignatureHelpRequest : IRequest + { + public MultiFileSignatureHelpRequest() + { + Files = new List(); + Packages = new List(); + } + + /// + /// List of files for signature help context + /// + public List Files { get; set; } + + /// + /// The specific file ID where signature help is requested + /// + public string TargetFileId { get; set; } + + /// + /// Position in the target file for signature help + /// + public int Position { get; set; } + + /// + /// NuGet packages to include + /// + public List Packages { get; set; } + + /// + /// For backward compatibility with single file requests + /// + public string Code { get; set; } + + /// + /// Whether this is a multi-file request + /// + public bool IsMultiFile => Files != null && Files.Count > 0; + } +} \ No newline at end of file diff --git a/MonacoRoslynCompletionProvider/Api/MultiFileTabCompletionRequest.cs b/MonacoRoslynCompletionProvider/Api/MultiFileTabCompletionRequest.cs new file mode 100644 index 0000000..263024e --- /dev/null +++ b/MonacoRoslynCompletionProvider/Api/MultiFileTabCompletionRequest.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace MonacoRoslynCompletionProvider.Api +{ + public class MultiFileTabCompletionRequest : IRequest + { + public MultiFileTabCompletionRequest() + { + Files = new List(); + Packages = new List(); + } + + /// + /// List of files for completion context + /// + public List Files { get; set; } + + /// + /// The specific file ID where completion is requested + /// + public string TargetFileId { get; set; } + + /// + /// Position in the target file for completion + /// + public int Position { get; set; } + + /// + /// NuGet packages to include + /// + public List Packages { get; set; } + + /// + /// For backward compatibility with single file requests + /// + public string Code { get; set; } + + /// + /// Whether this is a multi-file request + /// + public bool IsMultiFile => Files != null && Files.Count > 0; + } +} \ No newline at end of file diff --git a/MonacoRoslynCompletionProvider/CompletionWorkspace.cs b/MonacoRoslynCompletionProvider/CompletionWorkspace.cs index d54732b..3b3b4db 100644 --- a/MonacoRoslynCompletionProvider/CompletionWorkspace.cs +++ b/MonacoRoslynCompletionProvider/CompletionWorkspace.cs @@ -64,6 +64,7 @@ public sealed class CompletionWorkspace : IDisposable Assembly.Load("Microsoft.Net.Http.Headers, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60").Location, Assembly.Load("System.Security.Cryptography, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a").Location, Assembly.Load("Microsoft.AspNetCore.Http").Location, + Assembly.Load("System.Net.Mail, Version=9.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51").Location, typeof(ObjectExtengsion).Assembly.Location, // 假设此类型存在 typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly.Location, typeof(System.Diagnostics.Process).Assembly.Location, diff --git a/MonacoRoslynCompletionProvider/CompletitionRequestHandler.cs b/MonacoRoslynCompletionProvider/CompletitionRequestHandler.cs index 5e767c3..fc40f98 100644 --- a/MonacoRoslynCompletionProvider/CompletitionRequestHandler.cs +++ b/MonacoRoslynCompletionProvider/CompletitionRequestHandler.cs @@ -124,6 +124,338 @@ public static string FormatCode(string sourceCode) return Formatter.Format(root, workspace).ToFullString(); } + // Multi-file completion methods + public static async Task MultiFileCompletionHandle(MultiFileTabCompletionRequest request, string nuget) + { + var nugetAssembliesArray = DownloadNugetPackages.LoadPackages(nuget).Select(a => a.Path).ToArray(); + var workspace = await CompletionWorkspace.CreateAsync([.. nugetAssembliesArray, .. SAssemblies]); + + // Find the target file from the files list + var targetFile = request.Files.FirstOrDefault(f => f.FileName == request.TargetFileId) + ?? request.Files.FirstOrDefault(); + + if (targetFile == null) + { + return Array.Empty(); + } + + // Create a combined document with all files for better context + var combinedCode = CreateCombinedCode(request.Files, targetFile.FileName); + var document = await workspace.CreateDocumentAsync(combinedCode); + + // Adjust position to account for the combined code structure + var adjustedPosition = GetAdjustedPosition(request.Files, targetFile.FileName, request.Position); + + return await document.GetTabCompletion(adjustedPosition, CancellationToken.None); + } + + public static async Task MultiFileCodeCheckHandle(MultiFileCodeCheckRequest request, string nuget) + { + var nugetAssembliesArray = DownloadNugetPackages.LoadPackages(nuget).Select(a => a.Path).ToArray(); + var workspace = await CompletionWorkspace.CreateAsync([.. nugetAssembliesArray, .. SAssemblies]); + + // Create a combined document with all files; ensure target file (if provided) + // is included as-is so offsets map cleanly back to the original editor content. + var combinedCode = CreateCombinedCode(request.Files, request.TargetFileId); + var document = await workspace.CreateDocumentAsync(combinedCode); + + var results = await document.GetCodeCheckResults(CancellationToken.None); + + // Map diagnostics back to the active file offsets when in multi-file mode + return AdjustCodeCheckResults(results, request.Files, request.TargetFileId, combinedCode); + } + + public static async Task MultiFileHoverHandle(MultiFileHoverInfoRequest request, string nuget) + { + var nugetAssembliesArray = DownloadNugetPackages.LoadPackages(nuget).Select(a => a.Path).ToArray(); + var workspace = await CompletionWorkspace.CreateAsync([.. nugetAssembliesArray, .. SAssemblies]); + + var targetFile = request.Files.FirstOrDefault(f => f.FileName == request.TargetFileId) + ?? request.Files.FirstOrDefault(); + + if (targetFile == null) + { + return new HoverInfoResult(); + } + + var combinedCode = CreateCombinedCode(request.Files, targetFile.FileName); + var document = await workspace.CreateDocumentAsync(combinedCode); + + var adjustedPosition = GetAdjustedPosition(request.Files, targetFile.FileName, request.Position); + + return await document.GetHoverInformation(adjustedPosition, CancellationToken.None); + } + + public static async Task MultiFileSignatureHelpHandle(MultiFileSignatureHelpRequest request, string nuget) + { + var nugetAssembliesArray = DownloadNugetPackages.LoadPackages(nuget).Select(a => a.Path).ToArray(); + var workspace = await CompletionWorkspace.CreateAsync([.. nugetAssembliesArray, .. SAssemblies]); + + var targetFile = request.Files.FirstOrDefault(f => f.FileName == request.TargetFileId) + ?? request.Files.FirstOrDefault(); + + if (targetFile == null) + { + return new SignatureHelpResult(); + } + + var combinedCode = CreateCombinedCode(request.Files, targetFile.FileName); + var document = await workspace.CreateDocumentAsync(combinedCode); + + var adjustedPosition = GetAdjustedPosition(request.Files, targetFile.FileName, request.Position); + + return await document.GetSignatureHelp(adjustedPosition, CancellationToken.None); + } + + // Helper methods for multi-file support + private static string CreateCombinedCode(List files, string targetFileName = null) + { + var combinedCode = new System.Text.StringBuilder(); + + // Add using statements from all files first + var allUsingStatements = new HashSet(); + foreach (var file in files) + { + var lines = file.Content.Split('\n'); + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("using ") && trimmedLine.EndsWith(";")) + { + allUsingStatements.Add(trimmedLine); + } + } + } + + // Add all unique using statements + foreach (var usingStmt in allUsingStatements.OrderBy(u => u)) + { + combinedCode.AppendLine(usingStmt); + } + + combinedCode.AppendLine(); + + // Append each file's content directly (without artificial namespaces) + foreach (var file in files) + { + var fileName = Path.GetFileNameWithoutExtension(file.FileName); + var sanitizedName = System.Text.RegularExpressions.Regex.Replace(fileName, @"[^\w]", "_"); + + combinedCode.AppendLine($"// File: {file.FileName}"); + + // Remove using statements from individual files + var cleanContent = RemoveUsingStatements(file.Content); + + // Always include as-is (without extra wrappers), we already deduped usings. + combinedCode.AppendLine(cleanContent); + + combinedCode.AppendLine(); + } + + return combinedCode.ToString(); + } + + private static string RemoveUsingStatements(string code) + { + var lines = code.Split('\n'); + var result = new List(); + + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + if (!trimmedLine.StartsWith("using ") || !trimmedLine.EndsWith(";")) + { + result.Add(line); + } + } + + return string.Join("\n", result); + } + + private static string IndentCode(string code) + { + var lines = code.Split('\n'); + return string.Join("\n", lines.Select(line => string.IsNullOrWhiteSpace(line) ? line : " " + line)); + } + + private static int GetAdjustedPosition(List files, string targetFileName, int originalPosition) + { + // Calculate the position adjustment for the target file in the combined code + var position = 0; + + // Add using statements count + var allUsingStatements = new HashSet(); + foreach (var file in files) + { + var lines = file.Content.Split('\n'); + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("using ") && trimmedLine.EndsWith(";")) + { + allUsingStatements.Add(trimmedLine); + } + } + } + + // Each AppendLine adds an Environment.NewLine; the cleanContent preserves "\n" between lines. + position += allUsingStatements.Sum(u => u.Length + Environment.NewLine.Length) + Environment.NewLine.Length; // extra blank line + + // Add content before target file + foreach (var file in files) + { + if (file.FileName == targetFileName) + { + break; + } + + position += $"// File: {file.FileName}".Length + Environment.NewLine.Length; + var cleanContent = RemoveUsingStatements(file.Content); + // cleanContent itself uses "\n" newlines; AppendLine adds one Environment.NewLine after it + position += cleanContent.Length + Environment.NewLine.Length; // content + trailing EOL from AppendLine + + position += Environment.NewLine.Length; // Extra blank line after each file + } + + // Add the target file header + position += $"// File: {targetFileName}".Length + Environment.NewLine.Length; + + // Convert original position to the cleaned content + var targetFile = files.First(f => f.FileName == targetFileName); + var targetCleanContent = RemoveUsingStatements(targetFile.Content); + var originalLines = targetFile.Content.Split('\n'); + var cleanLines = targetCleanContent.Split('\n'); + + // Find the character position in the clean content + var currentPos = 0; + var linesProcessed = 0; + var originalChar = 0; + + foreach (var line in originalLines) + { + var trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("using ") && trimmedLine.EndsWith(";")) + { + // Skip using statements + originalChar += line.Length + 1; + continue; + } + + if (originalChar + line.Length >= originalPosition) + { + // We found the target line + var positionInLine = originalPosition - originalChar; + return position + currentPos + positionInLine; + } + + currentPos += line.Length + 1; // clean content uses \n + originalChar += line.Length + 1; // original uses \n + linesProcessed++; + } + + return position + originalPosition; + } + + private static CodeCheckResult[] AdjustCodeCheckResults(CodeCheckResult[] results, List files, string targetFileName, string combinedCode) + { + if (files == null || files.Count == 0 || string.IsNullOrEmpty(targetFileName)) + { + return results ?? Array.Empty(); + } + + var targetFile = files.FirstOrDefault(f => f.FileName == targetFileName) ?? files.First(); + var targetCleanContent = RemoveUsingStatements(targetFile.Content); + + // Compute the start of the target file's clean content within the combined code + var header = $"// File: {targetFile.FileName}" + Environment.NewLine; + var headerIndex = combinedCode.IndexOf(header, StringComparison.Ordinal); + if (headerIndex < 0) + { + return results ?? Array.Empty(); + } + var targetStartInCombined = headerIndex + header.Length; + var targetEndInCombined = targetStartInCombined + targetCleanContent.Length; + + // Build line start maps for original and clean content to convert offsets back + static List BuildLineStarts(string text) + { + var starts = new List { 0 }; + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '\n') + { + starts.Add(i + 1); + } + } + return starts; + } + + var originalLines = targetFile.Content.Split('\n'); + var isUsingLine = originalLines.Select(l => l.Trim().StartsWith("using ") && l.Trim().EndsWith(";")).ToArray(); + + var nonUsingOriginalIndices = new List(); + for (int i = 0; i < originalLines.Length; i++) + { + if (!isUsingLine[i]) nonUsingOriginalIndices.Add(i); + } + + var cleanContent = targetCleanContent; + var cleanLineStarts = BuildLineStarts(cleanContent); + + // Precompute original line starts + var originalContent = targetFile.Content; + var originalLineStarts = BuildLineStarts(originalContent); + + int CleanOffsetToOriginal(int cleanOffset) + { + // Identify the clean line index + int lineIdx = 0; + while (lineIdx + 1 < cleanLineStarts.Count && cleanLineStarts[lineIdx + 1] <= cleanOffset) + { + lineIdx++; + } + + int colInLine = cleanOffset - cleanLineStarts[lineIdx]; + if (lineIdx >= nonUsingOriginalIndices.Count) + { + // Out of bounds; clamp to end + return originalContent.Length; + } + int origLineIdx = nonUsingOriginalIndices[lineIdx]; + int origLineStart = originalLineStarts[origLineIdx]; + return Math.Min(origLineStart + colInLine, originalContent.Length); + } + + var mapped = new List(results?.Length ?? 0); + foreach (var r in results ?? Array.Empty()) + { + // Keep only diagnostics that fall within the target file's content region + if (r.OffsetTo <= targetStartInCombined || r.OffsetFrom >= targetEndInCombined) + { + continue; + } + + var fromClean = Math.Max(0, r.OffsetFrom - targetStartInCombined); + var toClean = Math.Max(0, Math.Min(r.OffsetTo, targetEndInCombined) - targetStartInCombined); + + var newFrom = CleanOffsetToOriginal(fromClean); + var newTo = CleanOffsetToOriginal(toClean); + + mapped.Add(new CodeCheckResult + { + Id = r.Id, + Keyword = r.Keyword, + Message = r.Message, + OffsetFrom = newFrom, + OffsetTo = Math.Max(newFrom, newTo), + Severity = r.Severity, + SeverityNumeric = r.SeverityNumeric + }); + } + + return mapped.ToArray(); + } + private class CSharpLanguage : ILanguageService { private static readonly LanguageVersion MaxLanguageVersion = Enum diff --git a/SharpPad/Controllers/CodeRunController.cs b/SharpPad/Controllers/CodeRunController.cs index f5db5f7..ea6388c 100644 --- a/SharpPad/Controllers/CodeRunController.cs +++ b/SharpPad/Controllers/CodeRunController.cs @@ -5,6 +5,8 @@ using Newtonsoft.Json; using System.Text.Json; using System.Threading.Channels; +using System.IO.Compression; +using static MonacoRoslynCompletionProvider.Api.CodeRunner; namespace SharpPad.Controllers { @@ -13,7 +15,7 @@ namespace SharpPad.Controllers public class CodeRunController : ControllerBase { [HttpPost("run")] - public async Task Run([FromBody] CodeRunRequest request) + public async Task Run([FromBody] MultiFileCodeRunRequest request) { string nugetPackages = string.Join(" ", request?.Packages.Select(p => $"{p.Id},{p.Version};{Environment.NewLine}") ?? []); @@ -33,15 +35,33 @@ public async Task Run([FromBody] CodeRunRequest request) // 创建处理 Channel 的任务 var processTask = ProcessChannelAsync(channel.Reader, cts.Token); - // 执行代码运行器 - var result = await CodeRunner.RunProgramCodeAsync( - request?.SourceCode, - nugetPackages, - request?.LanguageVersion ?? 2147483647, - message => OnOutputAsync(message, channel.Writer, cts.Token), - error => OnErrorAsync(error, channel.Writer, cts.Token), - request?.SessionId - ); + RunResult result; + + // Check if it's a multi-file request + if (request?.IsMultiFile == true) + { + // Execute multi-file code runner + result = await CodeRunner.RunMultiFileCodeAsync( + request.Files, + nugetPackages, + request?.LanguageVersion ?? 2147483647, + message => OnOutputAsync(message, channel.Writer, cts.Token), + error => OnErrorAsync(error, channel.Writer, cts.Token), + request?.SessionId + ); + } + else + { + // Backward compatibility: single file execution + result = await CodeRunner.RunProgramCodeAsync( + request?.SourceCode, + nugetPackages, + request?.LanguageVersion ?? 2147483647, + message => OnOutputAsync(message, channel.Writer, cts.Token), + error => OnErrorAsync(error, channel.Writer, cts.Token), + request?.SessionId + ); + } // 发送完成消息 await channel.Writer.WriteAsync($"data: {JsonConvert.SerializeObject(new { type = "completed", result })}\n\n", cts.Token); @@ -144,6 +164,69 @@ public IActionResult ProvideInput([FromBody] InputRequest request) var success = CodeRunner.ProvideInput(request.SessionId, request.Input); return Ok(new { success }); } + + [HttpPost("buildExe")] + public async Task BuildExe([FromBody] ExeBuildRequest request) + { + try + { + string nugetPackages = string.Join(" ", request?.Packages?.Select(p => $"{p.Id},{p.Version};{Environment.NewLine}") ?? []); + + ExeBuildResult result; + + if (request?.IsMultiFile == true) + { + result = await CodeRunner.BuildMultiFileExecutableAsync( + request.Files, + nugetPackages, + request?.LanguageVersion ?? 2147483647, + request?.OutputFileName ?? "Program.exe" + ); + } + else + { + result = await CodeRunner.BuildExecutableAsync( + request?.SourceCode, + nugetPackages, + request?.LanguageVersion ?? 2147483647, + request?.OutputFileName ?? "Program.exe" + ); + } + + if (result.Success) + { + // CodeRunner already produced the final artifact (zip or exe). + var filePath = result.ExeFilePath; + var fileName = Path.GetFileName(filePath); + var contentType = Path.GetExtension(fileName).Equals(".zip", StringComparison.OrdinalIgnoreCase) + ? "application/zip" + : "application/octet-stream"; + + var fileBytes = System.IO.File.ReadAllBytes(filePath); + + // Best-effort cleanup of the working directory + try + { + var workdir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(workdir) && Directory.Exists(workdir)) + { + Directory.Delete(workdir, recursive: true); + } + } + catch { /* ignore cleanup errors */ } + + return File(fileBytes, contentType, fileName); + } + else + { + return BadRequest(result); + } + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + } } public class InputRequest @@ -151,4 +234,4 @@ public class InputRequest public string SessionId { get; set; } public string Input { get; set; } } -} \ No newline at end of file +} diff --git a/SharpPad/Controllers/CompletionController.cs b/SharpPad/Controllers/CompletionController.cs index 518e7d2..2aac177 100644 --- a/SharpPad/Controllers/CompletionController.cs +++ b/SharpPad/Controllers/CompletionController.cs @@ -34,11 +34,26 @@ public async Task Hover([FromBody] HoverInfoRequest request) } [HttpPost("codeCheck")] - public async Task CodeCheck([FromBody] CodeCheckRequest request) + public async Task CodeCheck([FromBody] MultiFileCodeCheckRequest request) { string nugetPackages = string.Join(" ", request?.Packages.Select(p => $"{p.Id},{p.Version};{Environment.NewLine}") ?? []); - var codeCheckResults = await MonacoRequestHandler.CodeCheckHandle(request, nugetPackages); - return Ok(codeCheckResults); + + if (request?.IsMultiFile == true) + { + var codeCheckResults = await MonacoRequestHandler.MultiFileCodeCheckHandle(request, nugetPackages); + return Ok(codeCheckResults); + } + else + { + // Backward compatibility for single file requests + var singleRequest = new CodeCheckRequest + { + Code = request?.Code, + Packages = request?.Packages + }; + var codeCheckResults = await MonacoRequestHandler.CodeCheckHandle(singleRequest, nugetPackages); + return Ok(codeCheckResults); + } } [HttpPost("format")] @@ -127,5 +142,101 @@ public IActionResult TestCoreLibXmlDocumentation() MonacoRoslynCompletionProvider.CompletionWorkspace.ClearReferenceCache(); // 清理缓存以应用新的逻辑 return Ok(new { message = result }); } + + // Multi-file support endpoints + [HttpPost("multiFileComplete")] + public async Task MultiFileComplete([FromBody] MultiFileTabCompletionRequest request) + { + string nugetPackages = string.Join(" ", request?.Packages.Select(p => $"{p.Id},{p.Version};{Environment.NewLine}") ?? []); + + if (request?.IsMultiFile == true) + { + var tabCompletionResults = await MonacoRequestHandler.MultiFileCompletionHandle(request, nugetPackages); + return Ok(tabCompletionResults); + } + else + { + // Backward compatibility for single file requests + var singleRequest = new TabCompletionRequest + { + Code = request?.Code, + Position = request?.Position ?? 0, + Packages = request?.Packages + }; + var tabCompletionResults = await MonacoRequestHandler.CompletionHandle(singleRequest, nugetPackages); + return Ok(tabCompletionResults); + } + } + + [HttpPost("multiFileCodeCheck")] + public async Task MultiFileCodeCheck([FromBody] MultiFileCodeCheckRequest request) + { + string nugetPackages = string.Join(" ", request?.Packages.Select(p => $"{p.Id},{p.Version};{Environment.NewLine}") ?? []); + + if (request?.IsMultiFile == true) + { + var codeCheckResults = await MonacoRequestHandler.MultiFileCodeCheckHandle(request, nugetPackages); + return Ok(codeCheckResults); + } + else + { + // Backward compatibility for single file requests + var singleRequest = new CodeCheckRequest + { + Code = request?.Code, + Packages = request?.Packages + }; + var codeCheckResults = await MonacoRequestHandler.CodeCheckHandle(singleRequest, nugetPackages); + return Ok(codeCheckResults); + } + } + + [HttpPost("multiFileSignature")] + public async Task MultiFileSignature([FromBody] MultiFileSignatureHelpRequest request) + { + string nugetPackages = string.Join(" ", request?.Packages.Select(p => $"{p.Id},{p.Version};{Environment.NewLine}") ?? []); + + if (request?.IsMultiFile == true) + { + var signatureHelpResult = await MonacoRequestHandler.MultiFileSignatureHelpHandle(request, nugetPackages); + return Ok(signatureHelpResult); + } + else + { + // Backward compatibility for single file requests + var singleRequest = new SignatureHelpRequest + { + Code = request?.Code, + Position = request?.Position ?? 0, + Packages = request?.Packages + }; + var signatureHelpResult = await MonacoRequestHandler.SignatureHelpHandle(singleRequest, nugetPackages); + return Ok(signatureHelpResult); + } + } + + [HttpPost("multiFileHover")] + public async Task MultiFileHover([FromBody] MultiFileHoverInfoRequest request) + { + string nugetPackages = string.Join(" ", request?.Packages.Select(p => $"{p.Id},{p.Version};{Environment.NewLine}") ?? []); + + if (request?.IsMultiFile == true) + { + var hoverInfoResult = await MonacoRequestHandler.MultiFileHoverHandle(request, nugetPackages); + return Ok(hoverInfoResult); + } + else + { + // Backward compatibility for single file requests + var singleRequest = new HoverInfoRequest + { + Code = request?.Code, + Position = request?.Position ?? 0, + Packages = request?.Packages + }; + var hoverInfoResult = await MonacoRequestHandler.HoverHandle(singleRequest, nugetPackages); + return Ok(hoverInfoResult); + } + } } } \ No newline at end of file diff --git a/SharpPad/wwwroot/csharpLanguageProvider.js b/SharpPad/wwwroot/csharpLanguageProvider.js index b372d2f..11067fe 100644 --- a/SharpPad/wwwroot/csharpLanguageProvider.js +++ b/SharpPad/wwwroot/csharpLanguageProvider.js @@ -1,4 +1,4 @@ -import { getCurrentFile } from './utils/common.js'; +import { getCurrentFile, shouldUseMultiFileMode, createMultiFileRequest, createSingleFileRequest } from './utils/common.js'; import { sendRequest } from './utils/apiService.js'; export function registerCsharpProvider() { @@ -12,18 +12,29 @@ export function registerCsharpProvider() { const file = getCurrentFile(); const packages = file?.nugetConfig?.packages || []; + const packagesData = packages.map(p => ({ + Id: p.id, + Version: p.version + })); - let request = { - Code: model.getValue(), - Position: model.getOffsetAt(position), - Packages: packages.map(p => ({ - Id: p.id, - Version: p.version - })) + const useMultiFile = shouldUseMultiFileMode(); + let request; + let requestType; + + if (useMultiFile) { + request = createMultiFileRequest(file?.name, model.getOffsetAt(position), packagesData, model.getValue()); + requestType = "multiFileComplete"; + } else { + request = createSingleFileRequest(model.getValue(), model.getOffsetAt(position), packagesData); + requestType = "complete"; + } + + if (!request) { + return { suggestions }; } try { - const { data } = await sendRequest("complete", request); + const { data } = await sendRequest(requestType, request); for (let elem of data) { suggestions.push({ label: { @@ -48,18 +59,29 @@ export function registerCsharpProvider() { provideSignatureHelp: async (model, position) => { const file = getCurrentFile(); const packages = file?.nugetConfig?.packages || []; + const packagesData = packages.map(p => ({ + Id: p.id, + Version: p.version + })); - let request = { - Code: model.getValue(), - Position: model.getOffsetAt(position), - Packages: packages.map(p => ({ - Id: p.id, - Version: p.version - })) + const useMultiFile = shouldUseMultiFileMode(); + let request; + let requestType; + + if (useMultiFile) { + request = createMultiFileRequest(file?.name, model.getOffsetAt(position), packagesData, model.getValue()); + requestType = "multiFileSignature"; + } else { + request = createSingleFileRequest(model.getValue(), model.getOffsetAt(position), packagesData); + requestType = "signature"; + } + + if (!request) { + return null; } try { - const { data } = await sendRequest("signature", request); + const { data } = await sendRequest(requestType, request); if (!data) return; let signatures = []; @@ -100,18 +122,29 @@ export function registerCsharpProvider() { provideHover: async function (model, position) { const file = getCurrentFile(); const packages = file?.nugetConfig?.packages || []; + const packagesData = packages.map(p => ({ + Id: p.id, + Version: p.version + })); - let request = { - Code: model.getValue(), - Position: model.getOffsetAt(position), - Packages: packages.map(p => ({ - Id: p.id, - Version: p.version - })) + const useMultiFile = shouldUseMultiFileMode(); + let request; + let requestType; + + if (useMultiFile) { + request = createMultiFileRequest(file?.name, model.getOffsetAt(position), packagesData, model.getValue()); + requestType = "multiFileHover"; + } else { + request = createSingleFileRequest(model.getValue(), model.getOffsetAt(position), packagesData); + requestType = "hover"; + } + + if (!request) { + return null; } try { - const { data } = await sendRequest("hover", request); + const { data } = await sendRequest(requestType, request); if (!data) return null; const posStart = model.getPositionAt(data.offsetFrom); @@ -134,17 +167,31 @@ export function registerCsharpProvider() { async function validate() { const file = getCurrentFile(); const packages = file?.nugetConfig?.packages || []; + const packagesData = packages.map(p => ({ + Id: p.id, + Version: p.version + })); - let request = { - Code: model.getValue(), - Packages: packages.map(p => ({ - Id: p.id, - Version: p.version - })) + const useMultiFile = shouldUseMultiFileMode(); + let request; + let requestType; + + if (useMultiFile) { + request = createMultiFileRequest(file?.name, undefined, packagesData, model.getValue()); + if (!request) { + return; + } + requestType = "codeCheck"; + } else { + request = { + Code: model.getValue(), + Packages: packagesData + }; + requestType = "codeCheck"; } try { - const { data } = await sendRequest("codeCheck", request); + const { data } = await sendRequest(requestType, request); let markers = []; for (let elem of data) { @@ -235,4 +282,4 @@ export function registerCsharpProvider() { } } }); -} \ No newline at end of file +} diff --git a/SharpPad/wwwroot/editor/commands.js b/SharpPad/wwwroot/editor/commands.js index 3098b23..87dee3e 100644 --- a/SharpPad/wwwroot/editor/commands.js +++ b/SharpPad/wwwroot/editor/commands.js @@ -16,6 +16,17 @@ export class EditorCommands { } ); + // 复制当前行到下一行 (Ctrl+D) + this.editor.addCommand( + monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD, + () => { + const copyAction = this.editor.getAction('editor.action.copyLinesDownAction'); + if (copyAction) { + copyAction.run(); + } + } + ); + // 保存代码 (Ctrl+S) this.editor.addCommand( monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, @@ -59,4 +70,4 @@ export class EditorCommands { } }, true); } -} \ No newline at end of file +} diff --git a/SharpPad/wwwroot/execution/runner.js b/SharpPad/wwwroot/execution/runner.js index c649de0..9279288 100644 --- a/SharpPad/wwwroot/execution/runner.js +++ b/SharpPad/wwwroot/execution/runner.js @@ -7,6 +7,7 @@ const fileManager = new FileManager(); export class CodeRunner { constructor() { this.runButton = document.getElementById('runButton'); + this.buildExeButton = document.getElementById('buildExeButton'); this.outputContent = document.getElementById('outputContent'); this.notification = document.getElementById('notification'); this.currentSessionId = null; @@ -15,6 +16,7 @@ export class CodeRunner { initializeEventListeners() { this.runButton.addEventListener('click', () => this.runCode(window.editor.getValue())); + this.buildExeButton.addEventListener('click', () => this.buildExe(window.editor.getValue())); } appendOutput(message, type = 'info') { @@ -153,6 +155,9 @@ export class CodeRunner { fileManager.saveFileToLocalStorage(fileId, code); } + // 获取选中的文件用于多文件编译 + const selectedFiles = fileManager.getSelectedFiles(); + // 获取当前文件的包配置 const file = getCurrentFile(); const packages = file?.nugetConfig?.packages || []; @@ -163,15 +168,58 @@ export class CodeRunner { // 生成会话ID this.currentSessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); - const request = { - SourceCode: code, - Packages: packages.map(p => ({ - Id: p.id, - Version: p.version - })), - LanguageVersion: parseInt(csharpVersion), - SessionId: this.currentSessionId - }; + let request; + + // 如果有选中的文件,使用多文件模式 + if (selectedFiles.length > 0) { + // 保存当前编辑器的内容到对应文件 + const currentFileInSelection = selectedFiles.find(f => f.id === fileId); + if (currentFileInSelection) { + currentFileInSelection.content = code; + } else if (fileId) { + // 如果当前文件不在选中列表中,也添加进去 + const files = JSON.parse(localStorage.getItem('controllerFiles') || '[]'); + const currentFile = fileManager.findFileById(files, fileId); + if (currentFile && currentFile.name.endsWith('.cs')) { + selectedFiles.push({ + id: fileId, + name: currentFile.name, + content: code + }); + } + } + + // 检测入口文件(包含Main方法的文件) + const filesWithContent = selectedFiles.map(f => ({ + FileName: f.name, + Content: f.content, + IsEntry: f.content.includes('static void Main') || f.content.includes('static Task Main') || f.content.includes('static async Task Main') + })); + + request = { + Files: filesWithContent, + Packages: packages.map(p => ({ + Id: p.id, + Version: p.version + })), + LanguageVersion: parseInt(csharpVersion), + SessionId: this.currentSessionId + }; + + // 显示正在运行的文件列表 + this.appendOutput(`正在编译 ${selectedFiles.length} 个文件: ${selectedFiles.map(f => f.name).join(', ')}`, 'info'); + } else { + // 单文件模式(向后兼容) + request = { + SourceCode: code, + Packages: packages.map(p => ({ + Id: p.id, + Version: p.version + })), + LanguageVersion: parseInt(csharpVersion), + SessionId: this.currentSessionId + }; + } // 清空输出区域 this.outputContent.innerHTML = ''; @@ -348,4 +396,124 @@ export class CodeRunner { this.appendOutput('发送输入时出错: ' + error.message, 'error'); } } + + async buildExe(code) { + if (!code) { + this.appendOutput('没有代码可以构建', 'error'); + return; + } + + // 保存当前文件 + const fileId = document.querySelector('#fileListItems a.selected')?.getAttribute('data-file-id'); + if (fileId) { + fileManager.saveFileToLocalStorage(fileId, code); + } + + // 获取选中的文件用于多文件编译 + const selectedFiles = fileManager.getSelectedFiles(); + + // 获取当前文件的包配置 + const file = getCurrentFile(); + const packages = file?.nugetConfig?.packages || []; + + // 获取选择的C#版本 + const csharpVersion = document.getElementById('csharpVersion')?.value || 2147483647; + + let request; + let outputFileName = 'Program.exe'; + + // 如果有选中的文件,使用多文件模式 + if (selectedFiles.length > 0) { + // 保存当前编辑器的内容到对应文件 + const currentFileInSelection = selectedFiles.find(f => f.id === fileId); + if (currentFileInSelection) { + currentFileInSelection.content = code; + } else if (fileId) { + // 如果当前文件不在选中列表中,也添加进去 + const files = JSON.parse(localStorage.getItem('controllerFiles') || '[]'); + const currentFile = fileManager.findFileById(files, fileId); + if (currentFile && currentFile.name.endsWith('.cs')) { + selectedFiles.push({ + id: fileId, + name: currentFile.name, + content: code + }); + } + } + + // 检测入口文件(包含Main方法的文件) + const filesWithContent = selectedFiles.map(f => ({ + FileName: f.name, + Content: f.content, + IsEntry: f.content.includes('static void Main') || f.content.includes('static Task Main') || f.content.includes('static async Task Main') + })); + + // 从第一个文件名生成exe名称 + const mainFile = filesWithContent.find(f => f.IsEntry) || filesWithContent[0]; + if (mainFile) { + outputFileName = mainFile.FileName.replace('.cs', '.exe'); + } + + request = { + Files: filesWithContent, + Packages: packages.map(p => ({ + Id: p.id, + Version: p.version + })), + LanguageVersion: parseInt(csharpVersion), + OutputFileName: outputFileName + }; + + this.appendOutput(`正在构建 ${selectedFiles.length} 个文件为 ${outputFileName}...`, 'info'); + } else { + // 单文件模式(向后兼容) + // 尝试从当前文件名生成exe名称 + if (file && file.name && file.name.endsWith('.cs')) { + outputFileName = file.name.replace('.cs', '.exe'); + } + + request = { + SourceCode: code, + Packages: packages.map(p => ({ + Id: p.id, + Version: p.version + })), + LanguageVersion: parseInt(csharpVersion), + OutputFileName: outputFileName + }; + + this.appendOutput(`正在构建 ${outputFileName}...`, 'info'); + } + + // 禁用构建按钮防止重复点击 + this.buildExeButton.disabled = true; + this.buildExeButton.textContent = '构建中...'; + + try { + const result = await sendRequest('buildExe', request); + + if (result.success) { + this.appendOutput(`✅ 成功构建 ${result.fileName},文件已开始下载`, 'success'); + this.notification.textContent = `构建成功:${result.fileName}`; + this.notification.style.backgroundColor = 'rgba(76, 175, 80, 0.9)'; + this.notification.style.display = 'block'; + + // 3秒后隐藏通知 + setTimeout(() => { + this.notification.style.display = 'none'; + }, 3000); + } else { + this.appendOutput('构建失败', 'error'); + } + } catch (error) { + this.appendOutput('构建失败: ' + error.message, 'error'); + this.notification.textContent = '构建失败'; + this.notification.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + this.notification.style.display = 'block'; + } finally { + // 重新启用构建按钮 + this.buildExeButton.disabled = false; + this.buildExeButton.textContent = '构建EXE'; + } + } } \ No newline at end of file diff --git a/SharpPad/wwwroot/fileSystem/fileListResizer.js b/SharpPad/wwwroot/fileSystem/fileListResizer.js index bdb5e4e..63e747b 100644 --- a/SharpPad/wwwroot/fileSystem/fileListResizer.js +++ b/SharpPad/wwwroot/fileSystem/fileListResizer.js @@ -48,9 +48,9 @@ export class FileListResizer { } this.rafId = requestAnimationFrame(() => { - // 根据设备类型设置最小宽度和最大宽度,最大宽度缩小25% - const minWidth = isMobileDevice() ? 200 : 290; - const maxWidth = window.innerWidth * (isMobileDevice() ? 0.19 : 0.27); + // 根据设备类型设置最小/最大宽度,稍微放宽桌面端范围 + const minWidth = isMobileDevice() ? 240 : 340; + const maxWidth = window.innerWidth * (isMobileDevice() ? 0.22 : 0.33); const width = Math.min(Math.max(this.startWidth + (e.clientX - this.startX), minWidth), maxWidth); this.fileList.style.width = `${width}px`; @@ -95,4 +95,4 @@ export class FileListResizer { const fileListResizer = new FileListResizer(); // 导出实例以供其他模块使用 -export { fileListResizer }; \ No newline at end of file +export { fileListResizer }; diff --git a/SharpPad/wwwroot/fileSystem/fileManager.js b/SharpPad/wwwroot/fileSystem/fileManager.js index a416e52..feee51d 100644 --- a/SharpPad/wwwroot/fileSystem/fileManager.js +++ b/SharpPad/wwwroot/fileSystem/fileManager.js @@ -42,6 +42,31 @@ class FileManager { addFolderBtn.addEventListener('click', () => this.addFolder()); } + // 选择所有CS文件按钮监听 + const selectAllCsBtn = document.getElementById('selectAllCsBtn'); + if (selectAllCsBtn) { + selectAllCsBtn.addEventListener('click', () => { + this.selectAllCSharpFiles(); + this.updateSelectedFileInfo(); + }); + } + + // 清除选择按钮监听 + const clearSelectionBtn = document.getElementById('clearSelectionBtn'); + if (clearSelectionBtn) { + clearSelectionBtn.addEventListener('click', () => { + this.clearFileSelection(); + this.updateSelectedFileInfo(); + }); + } + + // 监听文件选择复选框变化 + document.addEventListener('change', (e) => { + if (e.target.classList.contains('file-select-checkbox')) { + this.updateSelectedFileInfo(); + } + }); + // 初始化右键菜单事件 this.initializeContextMenus(); } @@ -227,6 +252,17 @@ class FileManager { // 创建文件链接 const fileContainer = document.createElement('div'); fileContainer.className = 'file-container'; + fileContainer.style.display = 'flex'; + fileContainer.style.alignItems = 'center'; + fileContainer.style.gap = '5px'; + + // 添加复选框用于多文件选择 + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'file-select-checkbox'; + checkbox.setAttribute('data-file-checkbox', file.id); + checkbox.style.cursor = 'pointer'; + checkbox.title = '选择此文件进行多文件编译'; const a = document.createElement('a'); a.href = '#'; @@ -252,6 +288,7 @@ class FileManager { this.showContextMenu(e, file); }); + fileContainer.appendChild(checkbox); fileContainer.appendChild(a); li.appendChild(fileContainer); } @@ -828,12 +865,12 @@ class FileManager { const file = findFile(files); if (!file) return; - if (window.nugetManager) { - window.nugetManager.open(file); - return; - } - - const dialog = document.getElementById('nugetConfigDialog'); + if (window.nugetManager) { + window.nugetManager.open(file); + return; + } + + const dialog = document.getElementById('nugetConfigDialog'); dialog.style.display = 'block'; } @@ -1223,6 +1260,64 @@ class FileManager { } } + // 获取所有选中的文件 + getSelectedFiles() { + const selectedFiles = []; + const checkboxes = document.querySelectorAll('.file-select-checkbox:checked'); + const files = JSON.parse(localStorage.getItem('controllerFiles') || '[]'); + + checkboxes.forEach(checkbox => { + const fileId = checkbox.getAttribute('data-file-checkbox'); + const file = this.findFileById(files, fileId); + if (file) { + // 获取文件内容 + const content = localStorage.getItem(`file_${fileId}`) || file.content || ''; + selectedFiles.push({ + id: fileId, + name: file.name, + content: content + }); + } + }); + + return selectedFiles; + } + + // 清除所有文件选择 + clearFileSelection() { + const checkboxes = document.querySelectorAll('.file-select-checkbox'); + checkboxes.forEach(checkbox => { + checkbox.checked = false; + }); + } + + // 选择所有CS文件 + selectAllCSharpFiles() { + const checkboxes = document.querySelectorAll('.file-select-checkbox'); + checkboxes.forEach(checkbox => { + const fileId = checkbox.getAttribute('data-file-checkbox'); + const files = JSON.parse(localStorage.getItem('controllerFiles') || '[]'); + const file = this.findFileById(files, fileId); + if (file && file.name.endsWith('.cs')) { + checkbox.checked = true; + } + }); + } + + // 更新选中文件信息显示 + updateSelectedFileInfo() { + const selectedFiles = this.getSelectedFiles(); + const infoDiv = document.getElementById('multiFileInfo'); + const countSpan = document.getElementById('selectedFileCount'); + + if (selectedFiles.length > 0) { + infoDiv.style.display = 'block'; + countSpan.textContent = selectedFiles.length; + } else { + infoDiv.style.display = 'none'; + } + } + showOnlyCurrentFolder(folderId) { // 获取 ul 元素 var ulElement = document.getElementById('fileListItems'); diff --git a/SharpPad/wwwroot/index.html b/SharpPad/wwwroot/index.html index e455d8a..4d7b252 100644 --- a/SharpPad/wwwroot/index.html +++ b/SharpPad/wwwroot/index.html @@ -22,14 +22,19 @@ -
+
+ +
+
@@ -65,6 +70,7 @@
+
diff --git a/SharpPad/wwwroot/styles/base.css b/SharpPad/wwwroot/styles/base.css index 97993e4..5a5e98a 100644 --- a/SharpPad/wwwroot/styles/base.css +++ b/SharpPad/wwwroot/styles/base.css @@ -174,8 +174,8 @@ body.theme-light .chat-messages .assistant-message { } #container { - width: calc(100% - 290px - 520px); - margin-left: 290px; + width: calc(100% - 380px - 520px); + margin-left: 380px; margin-right: 520px; position: fixed; top: 0; @@ -275,9 +275,30 @@ body.theme-light #systemSettingsBtn:active { cursor: pointer; font-size: 14px; transition: background-color 0.2s; + margin-right: 10px; +} + +#buildExeButton { + padding: 5px 15px; + border: none; + border-radius: 4px; + background-color: #28a745; + color: white; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s; margin-right: 2.5rem; } +#buildExeButton:hover { + background-color: #218838; +} + +#buildExeButton:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + /* 主题按钮样式在 editorControls.css 中定义 */ @media only screen and (max-width: 768px) { @@ -367,4 +388,4 @@ a:hover { left: 10px; width: auto; } -} \ No newline at end of file +} diff --git a/SharpPad/wwwroot/styles/fileList.css b/SharpPad/wwwroot/styles/fileList.css index 5db2c09..69a2670 100644 --- a/SharpPad/wwwroot/styles/fileList.css +++ b/SharpPad/wwwroot/styles/fileList.css @@ -1,5 +1,5 @@ #fileList { - width: 290px; + width: 380px; border-right: 1px solid #444; box-sizing: border-box; background-color: #252526; @@ -369,8 +369,35 @@ body.theme-light .sort-button:hover { color: #0066b8; } +/* Multi-file selection info panel */ +.multi-file-info { + padding: 8px 12px; + font-size: 12px; + margin-bottom: 5px; + border-radius: 3px; + border-left: 3px solid #007acc; + background-color: #2d3142; + color: #e6e6e6; + transition: all 0.2s ease; +} + +body.theme-light .multi-file-info { + background-color: #e8f0fe; + color: #0066b8; + border-left-color: #0066b8; +} + +.multi-file-info span { + font-weight: 600; + color: #4fc3f7; +} + +body.theme-light .multi-file-info span { + color: #0066b8; +} + @media only screen and (max-width: 768px) { #fileList { display: none; } -} \ No newline at end of file +} diff --git a/SharpPad/wwwroot/utils/apiService.js b/SharpPad/wwwroot/utils/apiService.js index 1eff847..a89092c 100644 --- a/SharpPad/wwwroot/utils/apiService.js +++ b/SharpPad/wwwroot/utils/apiService.js @@ -10,8 +10,14 @@ export async function sendRequest(type, request) { case 'definition': endPoint = '/completion/definition'; break; case 'semanticTokens': endPoint = '/completion/semanticTokens'; break; case 'run': endPoint = '/api/coderun/run'; break; + case 'buildExe': endPoint = '/api/coderun/buildExe'; break; case 'addPackages': endPoint = '/completion/addPackages'; break; case 'codeActions': endPoint = '/completion/codeActions'; break; + // Multi-file endpoints + case 'multiFileComplete': endPoint = '/completion/multiFileComplete'; break; + case 'multiFileCodeCheck': endPoint = '/completion/multiFileCodeCheck'; break; + case 'multiFileSignature': endPoint = '/completion/multiFileSignature'; break; + case 'multiFileHover': endPoint = '/completion/multiFileHover'; break; default: throw new Error(`Unknown request type: ${type}`); } @@ -41,6 +47,52 @@ export async function sendRequest(type, request) { reader: response.body.getReader(), showNotificationTimer }; + } else if (type === 'buildExe') { + // 处理exe构建请求,返回文件下载 + const response = await fetch(endPoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + + clearTimeout(showNotificationTimer); + notification.style.display = 'none'; + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + // 获取文件名 + const contentDisposition = response.headers.get('Content-Disposition'); + let fileName = 'Program.exe'; + if (contentDisposition) { + const matches = contentDisposition.match(/filename="(.+)"/); + if (matches) { + fileName = matches[1]; + } + } + + // 如果是zip响应但文件名被回退成.exe,纠正为.zip + const respContentType = (response.headers.get('Content-Type') || '').toLowerCase(); + if (respContentType.includes('zip') && fileName.toLowerCase().endsWith('.exe')) { + fileName = fileName.replace(/\.exe$/i, '.zip'); + } + + // 下载文件 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + return { success: true, fileName }; } else { const response = await fetch(endPoint, { method: 'POST', @@ -74,4 +126,4 @@ export async function sendRequest(type, request) { notification.style.display = 'block'; throw error; } -} \ No newline at end of file +} diff --git a/SharpPad/wwwroot/utils/common.js b/SharpPad/wwwroot/utils/common.js index 24813c3..185dad1 100644 --- a/SharpPad/wwwroot/utils/common.js +++ b/SharpPad/wwwroot/utils/common.js @@ -28,6 +28,90 @@ export function getCurrentFile() { return findFile(files); } +// 获取所有选中的文件 +export function getSelectedFiles() { + const selectedFiles = []; + const checkboxes = document.querySelectorAll('.file-select-checkbox:checked'); + const filesData = localStorage.getItem('controllerFiles'); + const files = filesData ? JSON.parse(filesData) : []; + + checkboxes.forEach(checkbox => { + const fileId = checkbox.getAttribute('data-file-checkbox'); + const file = findFileById(files, fileId); + if (file) { + // 获取文件内容 + const content = localStorage.getItem(`file_${fileId}`) || file.content || ''; + selectedFiles.push({ + FileName: file.name, + Content: content + }); + } + }); + + return selectedFiles; +} + +// 递归查找文件 +function findFileById(files, id) { + for (const file of files) { + if (file.id === id) return file; + if (file.type === 'folder' && file.files) { + const found = findFileById(file.files, id); + if (found) return found; + } + } + return null; +} + +// 判断是否应该使用多文件模式 +export function shouldUseMultiFileMode() { + const selectedFiles = getSelectedFiles(); + return selectedFiles.length > 0; +} + +// 创建多文件请求 +export function createMultiFileRequest(targetFileName, position, packages, currentContent) { + const selectedFiles = getSelectedFiles(); + const normalizedPackages = Array.isArray(packages) ? packages : []; + + if (targetFileName && typeof currentContent === 'string') { + const targetFile = selectedFiles.find(f => f.FileName === targetFileName); + if (targetFile) { + targetFile.Content = currentContent; + } else { + selectedFiles.unshift({ + FileName: targetFileName, + Content: currentContent + }); + } + } + + if (selectedFiles.length === 0) { + return null; + } + + const request = { + Files: selectedFiles, + TargetFileId: targetFileName, + Packages: normalizedPackages + }; + + if (typeof position === 'number') { + request.Position = position; + } + + return request; +} + +// 创建单文件请求(向后兼容) +export function createSingleFileRequest(code, position, packages) { + return { + Code: code, + Position: position, + Packages: packages + }; +} + export function layoutEditor() { if (window.editor) { setTimeout(() => { diff --git a/notify-task-complete.ps1 b/notify-task-complete.ps1 new file mode 100644 index 0000000..abdf104 --- /dev/null +++ b/notify-task-complete.ps1 @@ -0,0 +1,22 @@ +# Windows notification script for Claude Code task completion +param( + [string]$Message = "Claude Code task completed", + [string]$Title = "Claude Code" +) + +try { + # Try using BurntToast module if available + if (Get-Module -ListAvailable -Name BurntToast) { + Import-Module BurntToast + New-BurntToastNotification -Text $Title, $Message + exit 0 + } + + # Fallback: Use msg command for simple popup + $msgText = "$Title`n$Message" + Start-Process -FilePath "msg.exe" -ArgumentList @("*", "/time:5", $msgText) -NoNewWindow -Wait + +} catch { + # Ultimate fallback: Write to console + Write-Host "[$Title] $Message" -ForegroundColor Green +} \ No newline at end of file