feat: add DSH IDE open-file references

This commit is contained in:
2026-08-17 11:52:48 +08:00
commit d96c11ae3d
42 changed files with 6991 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prophet.CoopWithDsh", "Prophet.CoopWithDsh\Prophet.CoopWithDsh.csproj", "{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prophet.CoopWithDsh.Tests", "Prophet.CoopWithDsh.Tests\Prophet.CoopWithDsh.Tests.csproj", "{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}.Release|Any CPU.Build.0 = Release|Any CPU
{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Prophet.CoopWithDsh\SnapshotModels.cs" Link="SnapshotModels.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
using System;
using Xunit;
namespace Prophet.CoopWithDsh.Tests
{
public sealed class SnapshotModelTests
{
[Fact]
public void CreateDeduplicatesPathsAndPreservesDirtyState()
{
var snapshot = SnapshotModel.Create("one", 10, "F:\\Project\\App.sln", new[] {
new OpenDocument { Path = "F:\\Project\\main.cs", Label = "main.cs", Dirty = false },
new OpenDocument { Path = "f:\\project\\MAIN.cs", Label = "MAIN.cs", Dirty = true },
}, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
Assert.Equal(1, snapshot.Version);
Assert.Equal("visualstudio", snapshot.Ide);
Assert.Single(snapshot.Documents);
Assert.True(snapshot.Documents[0].Dirty);
Assert.Equal("2026-01-01T00:00:00.0000000Z", snapshot.UpdatedAt);
}
}
}
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using Microsoft.VisualStudio.TextManager.Interop;
using Task = System.Threading.Tasks.Task;
namespace Prophet.CoopWithDsh
{
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[InstalledProductRegistration("DSH Open File Bridge", "Publishes open file metadata for DeepSeek Harness", "0.1.0")]
[ProvideAutoLoad(UIContextGuids80.NoSolution, PackageAutoLoadFlags.BackgroundLoad)]
[ProvideAutoLoad(UIContextGuids80.SolutionExists, PackageAutoLoadFlags.BackgroundLoad)]
[Guid(PackageGuidString)]
public sealed class CoopWithDshPackage : AsyncPackage, IVsRunningDocTableEvents
{
public const string PackageGuidString = "1e02d547-499b-41ed-a25e-b356a9687e31";
private const int DebounceMilliseconds = 200;
private const int HeartbeatMilliseconds = 15000;
private IVsRunningDocumentTable? runningDocuments;
private IVsSolution? solution;
private SnapshotPublisher? publisher;
private Timer? debounceTimer;
private Timer? heartbeatTimer;
private uint eventsCookie;
private bool disposing;
protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
{
await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
runningDocuments = await GetServiceAsync(typeof(SVsRunningDocumentTable)) as IVsRunningDocumentTable;
solution = await GetServiceAsync(typeof(SVsSolution)) as IVsSolution;
if (runningDocuments == null) throw new InvalidOperationException("Visual Studio Running Document Table is unavailable.");
publisher = new SnapshotPublisher();
ErrorHandler.ThrowOnFailure(runningDocuments.AdviseRunningDocTableEvents(this, out eventsCookie));
debounceTimer = new Timer(_ => JoinableTaskFactory.RunAsync(PublishAsync), null, Timeout.Infinite, Timeout.Infinite);
heartbeatTimer = new Timer(_ => JoinableTaskFactory.RunAsync(PublishAsync), null, HeartbeatMilliseconds, HeartbeatMilliseconds);
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
await PublishAsync();
}
private void SchedulePublish()
{
if (!disposing) debounceTimer?.Change(DebounceMilliseconds, Timeout.Infinite);
}
private async Task PublishAsync()
{
if (disposing || publisher == null || runningDocuments == null) return;
await JoinableTaskFactory.SwitchToMainThreadAsync();
var documents = EnumerateDocuments(runningDocuments);
var workspace = ReadSolutionPath(solution);
var snapshot = SnapshotModel.Create(publisher.InstanceId, Process.GetCurrentProcess().Id, workspace, documents, DateTimeOffset.UtcNow);
try { publisher.Publish(snapshot); } catch (Exception error) { Trace.WriteLine($"DSH Open File Bridge publish failed: {error}"); }
}
private static IReadOnlyList<OpenDocument> EnumerateDocuments(IVsRunningDocumentTable table)
{
ThreadHelper.ThrowIfNotOnUIThread();
ErrorHandler.ThrowOnFailure(table.GetRunningDocumentsEnum(out var iterator));
var result = new List<OpenDocument>();
var cookies = new uint[1];
while (iterator.Next(1, cookies, out var fetched) == VSConstants.S_OK && fetched == 1)
{
IntPtr docData = IntPtr.Zero;
try
{
ErrorHandler.ThrowOnFailure(table.GetDocumentInfo(cookies[0], out _, out _, out _, out var moniker, out _, out _, out docData));
if (string.IsNullOrWhiteSpace(moniker) || !Path.IsPathRooted(moniker) || !File.Exists(moniker)) continue;
var dirty = false;
if (docData != IntPtr.Zero)
{
var value = Marshal.GetObjectForIUnknown(docData);
if (value is IVsPersistDocData persist && ErrorHandler.Succeeded(persist.IsDocDataDirty(out var isDirty))) dirty = isDirty != 0;
}
result.Add(new OpenDocument { Path = Path.GetFullPath(moniker), Label = Path.GetFileName(moniker), Dirty = dirty });
}
catch (Exception error) { Trace.WriteLine($"DSH Open File Bridge skipped one document: {error.Message}"); }
finally { if (docData != IntPtr.Zero) Marshal.Release(docData); }
}
return result;
}
private static string? ReadSolutionPath(IVsSolution? solutionService)
{
ThreadHelper.ThrowIfNotOnUIThread();
if (solutionService == null) return null;
return ErrorHandler.Succeeded(solutionService.GetSolutionInfo(out _, out var solutionFile, out _)) && !string.IsNullOrWhiteSpace(solutionFile)
? solutionFile : null;
}
private void OnProcessExit(object sender, EventArgs e) => publisher?.Dispose();
protected override void Dispose(bool disposing)
{
this.disposing = true;
if (disposing)
{
AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
debounceTimer?.Dispose();
heartbeatTimer?.Dispose();
if (runningDocuments != null && eventsCookie != 0)
{
JoinableTaskFactory.Run(async () => {
await JoinableTaskFactory.SwitchToMainThreadAsync();
runningDocuments.UnadviseRunningDocTableEvents(eventsCookie);
});
}
publisher?.Dispose();
}
base.Dispose(disposing);
}
public int OnAfterFirstDocumentLock(uint docCookie, uint lockType, uint readLocks, uint editLocks) { SchedulePublish(); return VSConstants.S_OK; }
public int OnBeforeLastDocumentUnlock(uint docCookie, uint lockType, uint readLocks, uint editLocks) { SchedulePublish(); return VSConstants.S_OK; }
public int OnAfterSave(uint docCookie) { SchedulePublish(); return VSConstants.S_OK; }
public int OnAfterAttributeChange(uint docCookie, uint attributes) { SchedulePublish(); return VSConstants.S_OK; }
public int OnBeforeDocumentWindowShow(uint docCookie, int firstShow, IVsWindowFrame frame) { SchedulePublish(); return VSConstants.S_OK; }
public int OnAfterDocumentWindowHide(uint docCookie, IVsWindowFrame frame) { SchedulePublish(); return VSConstants.S_OK; }
}
}
@@ -0,0 +1,7 @@
MIT License
Copyright (c) 2026 Prophet
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
@@ -0,0 +1,31 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>Prophet.CoopWithDsh</RootNamespace>
<AssemblyName>Prophet.CoopWithDsh</AssemblyName>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<GeneratePkgDefFile>true</GeneratePkgDefFile>
<CreateVsixContainer>true</CreateVsixContainer>
<DeployExtension>false</DeployExtension>
<IncludeAssemblyInVSIXContainer>true</IncludeAssemblyInVSIXContainer>
<IncludeDebugSymbolsInVSIXContainer>false</IncludeDebugSymbolsInVSIXContainer>
<CopyBuildOutputToOutputDirectory>true</CopyBuildOutputToOutputDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.SDK" Version="17.0.31902.203" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.VSSDK.BuildTools" Version="17.9.3168" PrivateAssets="all" GeneratePathProperty="true" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<None Include="source.extension.vsixmanifest" />
<Content Include="LICENSE.txt">
<IncludeInVSIX>true</IncludeInVSIX>
</Content>
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="$(PkgMicrosoft_VSSDK_BuildTools)\tools\vssdk\Microsoft.VsSDK.targets" />
<Target Name="BuildVsix" AfterTargets="Build" DependsOnTargets="CreateVsixContainer" />
</Project>
@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
namespace Prophet.CoopWithDsh
{
internal sealed class OpenDocument
{
[JsonProperty("path")] public string Path { get; set; } = string.Empty;
[JsonProperty("label")] public string Label { get; set; } = string.Empty;
[JsonProperty("dirty")] public bool Dirty { get; set; }
}
internal sealed class OpenFileSnapshot
{
[JsonProperty("version")] public int Version { get; set; } = 1;
[JsonProperty("instanceId")] public string InstanceId { get; set; } = string.Empty;
[JsonProperty("ide")] public string Ide { get; set; } = "visualstudio";
[JsonProperty("processId")] public int ProcessId { get; set; }
[JsonProperty("workspace", NullValueHandling = NullValueHandling.Ignore)] public string? Workspace { get; set; }
[JsonProperty("updatedAt")] public string UpdatedAt { get; set; } = string.Empty;
[JsonProperty("documents")] public IReadOnlyList<OpenDocument> Documents { get; set; } = Array.Empty<OpenDocument>();
}
internal static class SnapshotModel
{
internal static OpenFileSnapshot Create(string instanceId, int processId, string? workspace, IEnumerable<OpenDocument> documents, DateTimeOffset now)
{
var deduplicated = new Dictionary<string, OpenDocument>(StringComparer.OrdinalIgnoreCase);
foreach (var document in documents.Where(d => !string.IsNullOrWhiteSpace(d.Path) && Path.IsPathRooted(d.Path)))
{
if (!deduplicated.TryGetValue(document.Path, out var current) || (!current.Dirty && document.Dirty))
deduplicated[document.Path] = document;
}
return new OpenFileSnapshot
{
InstanceId = instanceId,
ProcessId = processId,
Workspace = string.IsNullOrWhiteSpace(workspace) ? null : workspace,
UpdatedAt = now.UtcDateTime.ToString("O"),
Documents = deduplicated.Values.OrderBy(d => d.Label, StringComparer.OrdinalIgnoreCase).ThenBy(d => d.Path, StringComparer.OrdinalIgnoreCase).ToArray(),
};
}
}
}
@@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Text;
using Newtonsoft.Json;
namespace Prophet.CoopWithDsh
{
internal sealed class SnapshotPublisher : IDisposable
{
private readonly object gate = new object();
internal string InstanceId { get; } = Guid.NewGuid().ToString("N");
internal string FilePath { get; }
internal SnapshotPublisher(string? localAppData = null)
{
var root = localAppData ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(root)) throw new InvalidOperationException("LOCALAPPDATA is unavailable.");
var directory = Path.Combine(root, "Prophet", "dsh-plugin-coop-with-vs", "v1", "instances");
FilePath = Path.Combine(directory, $"visualstudio-{InstanceId}.json");
}
internal void Publish(OpenFileSnapshot snapshot)
{
lock (gate)
{
var directory = Path.GetDirectoryName(FilePath)!;
Directory.CreateDirectory(directory);
var temporary = FilePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
File.WriteAllText(temporary, JsonConvert.SerializeObject(snapshot) + Environment.NewLine, new UTF8Encoding(false));
try
{
if (File.Exists(FilePath)) File.Replace(temporary, FilePath, null);
else File.Move(temporary, FilePath);
}
catch
{
TryDelete(temporary);
throw;
}
}
}
public void Dispose() => TryDelete(FilePath);
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { }
}
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
<Metadata>
<Identity Id="Prophet.CoopWithDsh.1e02d547-499b-41ed-a25e-b356a9687e31" Version="0.1.0" Language="en-US" Publisher="Prophet" />
<DisplayName>DSH Open File Bridge</DisplayName>
<Description xml:space="preserve">Publishes Visual Studio open file metadata for DeepSeek Harness @ references.</Description>
<License>LICENSE.txt</License>
</Metadata>
<Installation>
<InstallationTarget Id="Microsoft.VisualStudio.Community" Version="[17.0,18.0)"><ProductArchitecture>amd64</ProductArchitecture></InstallationTarget>
<InstallationTarget Id="Microsoft.VisualStudio.Pro" Version="[17.0,18.0)"><ProductArchitecture>amd64</ProductArchitecture></InstallationTarget>
<InstallationTarget Id="Microsoft.VisualStudio.Enterprise" Version="[17.0,18.0)"><ProductArchitecture>amd64</ProductArchitecture></InstallationTarget>
</Installation>
<Dependencies />
<Prerequisites>
<Prerequisite Id="Microsoft.VisualStudio.Component.CoreEditor" Version="[17.0,18.0)" DisplayName="Visual Studio core editor" />
</Prerequisites>
<Assets>
<Asset Type="Microsoft.VisualStudio.VsPackage" d:Source="Project" d:ProjectName="Prophet.CoopWithDsh" Path="|Prophet.CoopWithDsh;PkgdefProjectOutputGroup|" />
</Assets>
</PackageManifest>
+17
View File
@@ -0,0 +1,17 @@
# DSH Open File Bridge for Visual Studio 2022
This VSIX is a background-loaded `AsyncPackage`. Each Visual Studio process enumerates its own `IVsRunningDocumentTable`, listens for RDT changes, and publishes one protocol-v1 snapshot. This avoids the ambiguity and integrity-level failures of attaching externally through EnvDTE/ROT.
Only absolute, existing local files are published. Dirty state is read through `IVsPersistDocData`; source buffers are never copied.
## Build and test
Use a Visual Studio Developer PowerShell or a machine with the VS 2022 extension workload:
```powershell
dotnet restore CoopWithDsh.sln
dotnet test CoopWithDsh.sln -c Release
dotnet build Prophet.CoopWithDsh\Prophet.CoopWithDsh.csproj -c Release
```
Install the generated `.vsix` from the Release output, restart Visual Studio, then type `@` in DSH. The snapshot heartbeats every 15 seconds and is removed on normal package/process shutdown.