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
@@ -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>