Visual Studio 2022でドキュメントを複製し別タブグループへ自動移動するC#拡張の実装ガイド

Visual Studio 2022 で「Window → New Window」相当の複製タブを作り、すぐ隣のタブグループへ自動で移したい──この一連の操作を C# 製 VSIX で確実に再現するための実装手順を、API 解説から堅牢なサンプルコード、落とし穴の回避策までまとめました。IDE の UI スレッドや DTE コマンドの扱いも丁寧に整理しています。

目次

質問の背景とゴール

IDE 上で開いたドキュメント(例:main.cpp)を複製して main.cpp:2 のような「コピー」を生成し、生成直後の複製タブを隣のドキュメント・タブグループへ自動で移動させたい。ユーザー操作では「Window → New Window」→「Window → Move to Next Tab Group」の連続実行で実現できますが、拡張機能から同等の体験をワンクリックで提供するのが目的です。

結論(最短レシピ)

以下の 3 ステップで安定して実現できます。

手順具体的 API/操作ポイント
複製を作成IVsUIShellOpenDocument.OpenCopyOfStandardEditor既存 IVsWindowFrame から file:2 などのコピーを生成(New Window 相当)。logicalView は通常 VSConstants.LOGVIEWID_Primary
複製タブの取得cloneFrame.Show()cloneFrame.GetProperty(VSFPROPID_ExtWindowObject)EnvDTE.Window を取得できるため、以降は DTE コマンドを利用可能。
タブを移動window.Activate()DTE.ExecuteCommand("Window.MoveToNextTabGroup")IDE メニュー「Move to Next Tab Group」と同等。左側へ送る場合は "Window.MoveToPreviousTabGroup"

最小コード(抜粋)

public async Task DuplicateIntoOtherGroupAsync(IVsWindowFrame sourceFrame)
{
    // UI スレッドで COM を触る
    await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();


// 1. 複製を生成
var openDoc = await AsyncServiceProvider.GlobalProvider
                  .GetServiceAsync(typeof(SVsUIShellOpenDocument))
              as IVsUIShellOpenDocument;

Guid view = VSConstants.LOGVIEWID_Primary;
ErrorHandler.ThrowOnFailure(
    openDoc.OpenCopyOfStandardEditor(sourceFrame, ref view, out var cloneFrame)
);

// 2. 複製タブを表示・取得
ErrorHandler.ThrowOnFailure(cloneFrame.Show());
ErrorHandler.ThrowOnFailure(cloneFrame.GetProperty(
    (int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object winObj));
var win = (EnvDTE.Window)winObj;

// 3. 別グループへ移動
win.Activate(); // アクティブ化
var dte = (EnvDTE80.DTE2)Package.GetGlobalService(typeof(SDTE));
dte.ExecuteCommand("Window.MoveToNextTabGroup");


} 

実装手順(詳細解説)

前提:AsyncPackage と UI スレッド

Visual Studio の多くのサービスは UI スレッド(メインスレッド)での呼び出しが前提です。VSIX は AsyncPackage(非同期パッケージ)で実装し、COM へ触れる直前で SwitchToMainThreadAsync() を呼び出して UI スレッドへ戻します。これにより応答性を損なわずに実装できます。

[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[InstalledProductRegistration("DuplicateAndMove", "Duplicate document and move to next group", "1.0")]
[ProvideMenuResource("Menus.ctmenu", 1)]
public sealed class DuplicateAndMovePackage : AsyncPackage
{
    protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
    {
        await this.JoinableTaskFactory.SwitchToMainThreadAsync();
        await DuplicateAndMoveCommand.InitializeAsync(this);
    }
}

アクティブドキュメントの IVsWindowFrame を取得する

メニューコマンドなど「現在アクティブなドキュメント」を対象にする場合、まず DTE でパスを取り、VsShellUtilities.IsDocumentOpen から IVsWindowFrame を得るのがシンプルです。

private static async Task<IVsWindowFrame> GetActiveDocumentFrameAsync(AsyncPackage package)
{
    await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

    var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
    var doc = dte?.ActiveDocument;
    if (doc == null) return null;

    IVsUIHierarchy hier;
    IVsWindowFrame frame;
    uint itemid;

    bool isOpen = VsShellUtilities.IsDocumentOpen(
        package, doc.FullName, Guid.Empty, out hier, out itemid, out frame);

    return isOpen ? frame : null;
}

すでに IVsWindowFrame を引数でもらえる設計(エディタごとのコンテキストメニューなど)の場合は、この取得処理は不要です。

複製の作成:OpenCopyOfStandardEditor

複製(New Window 相当)は IVsUIShellOpenDocument.OpenCopyOfStandardEditor を使います。logicalViewVSConstants.LOGVIEWID_Primary を指定すれば通常のコードエディタのコピーが得られます。デザイナ等の別ビューをコピーしたい場合は該当 GUID を使います(後述の表を参照)。

Guid logicalView = VSConstants.LOGVIEWID_Primary;
var openDoc = await package.GetServiceAsync(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
ErrorHandler.ThrowOnFailure(openDoc.OpenCopyOfStandardEditor(sourceFrame, ref logicalView, out var cloneFrame));

複製タブの DTE ラッパーを取得する

作成した IVsWindowFrame から VSFPROPID_ExtWindowObject を取り出すと EnvDTE.Window が得られます。DTE コマンドを使う際はこのラッパーが便利です。

ErrorHandler.ThrowOnFailure(cloneFrame.Show());
ErrorHandler.ThrowOnFailure(cloneFrame.GetProperty(
    (int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object obj));
var dteWindow = (EnvDTE.Window)obj;

タブを隣のタブグループへ移動する

対象ウィンドウを Activate() したうえで、DTE コマンド Window.MoveToNextTabGroup を実行します。隣ではなく左側へ送りたい場合は Window.MoveToPreviousTabGroup を使います。

dteWindow.Activate();
var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
dte.ExecuteCommand("Window.MoveToNextTabGroup");

注意点として、環境によっては隣のタブグループが存在しないとコマンドが無効な場合があります。その際は先に Window.NewVerticalTabGroup(または Window.NewHorizontalTabGroup)を実行してグループを作ってから移動させると確実です。

bool TryExec(EnvDTE80.DTE2 d, string name)
{
    try
    {
        var cmd = d.Commands.Item(name);
        if (cmd != null && cmd.IsAvailable)
        {
            d.ExecuteCommand(name);
            return true;
        }
    }
    catch { }
    return false;
}

// 利用例
dteWindow.Activate();
if (!TryExec(dte, "Window.MoveToNextTabGroup"))
{
    // 隣が無ければ縦のタブグループを作る
    TryExec(dte, "Window.NewVerticalTabGroup");
    TryExec(dte, "Window.MoveToNextTabGroup");
}

フル実装:ワンクリックで「複製 → 隣へ移動」

メニューコマンドから起動できる形で、例外処理や UI スレッド制御を含めた完全版です。

internal sealed class DuplicateAndMoveCommand
{
    public const int CommandId = 0x0200;
    public static readonly Guid CommandSet = new Guid("b0d9f0c3-0f76-4a2e-8f2e-8a4e77e09a42");
    private readonly AsyncPackage _package;

    private DuplicateAndMoveCommand(AsyncPackage package, OleMenuCommandService commandService)
    {
        _package = package;
        var cmdId = new CommandID(CommandSet, CommandId);
        var menuItem = new OleMenuCommand(Execute, cmdId);
        commandService.AddCommand(menuItem);
    }

    public static async Task InitializeAsync(AsyncPackage package)
    {
        await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
        var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService;
        _ = new DuplicateAndMoveCommand(package, commandService);
    }

    private async void Execute(object sender, EventArgs e)
    {
        try
        {
            await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

            var frame = await GetActiveDocumentFrameAsync(_package);
            if (frame == null) return;

            await DuplicateIntoOtherGroupAsync(frame);
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine(ex);
        }
    }

    private async Task DuplicateIntoOtherGroupAsync(IVsWindowFrame sourceFrame)
    {
        await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

        var openDoc = await _package.GetServiceAsync(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
        Assumes.Present(openDoc);

        Guid logicalView = VSConstants.LOGVIEWID_Primary;
        ErrorHandler.ThrowOnFailure(openDoc.OpenCopyOfStandardEditor(sourceFrame, ref logicalView, out var cloneFrame));

        ErrorHandler.ThrowOnFailure(cloneFrame.Show());
        ErrorHandler.ThrowOnFailure(cloneFrame.GetProperty((int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object obj));
        var win = (EnvDTE.Window)obj;

        win.Activate();
        var dte = (EnvDTE80.DTE2)await _package.GetServiceAsync(typeof(SDTE));

        if (!TryExec(dte, "Window.MoveToNextTabGroup"))
        {
            // 隣のグループが無い場合は新規作成してから移動
            TryExec(dte, "Window.NewVerticalTabGroup");
            TryExec(dte, "Window.MoveToNextTabGroup");
        }
    }

    private static bool TryExec(EnvDTE80.DTE2 d, string name)
    {
        try
        {
            var cmd = d.Commands.Item(name);
            if (cmd != null && cmd.IsAvailable)
            {
                d.ExecuteCommand(name);
                return true;
            }
        }
        catch { }
        return false;
    }

    private static async Task<IVsWindowFrame> GetActiveDocumentFrameAsync(AsyncPackage package)
    {
        await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
        var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
        var doc = dte?.ActiveDocument;
        if (doc == null) return null;

        IVsUIHierarchy hier;
        IVsWindowFrame frame;
        uint itemid;

        bool isOpen = VsShellUtilities.IsDocumentOpen(package, doc.FullName, Guid.Empty, out hier, out itemid, out frame);
        return isOpen ? frame : null;
    }
}

ビューごとの logicalView と関連プロパティ

コードエディタ以外のビュー(フォームデザイナなど)を複製したいときは、OpenCopyOfStandardEditorlogicalView を差し替えます。代表的な GUID は以下のとおりです。

用途定数備考
通常のコード編集VSConstants.LOGVIEWID_Primaryもっとも一般的なビュー
デザインビューVSConstants.LOGVIEWID_Designerフォームや XAML デザイナなど
コードのみVSConstants.LOGVIEWID_Codeコードに特化したビュー
テキストVSConstants.LOGVIEWID_TextView基本テキストエディタ

ウィンドウプロパティを触る際のキーもまとめておきます。

プロパティ名概要
__VSFPROPID.VSFPROPID_ExtWindowObjectDTE ラッパー(EnvDTE.Windowobject
__VSFPROPID.VSFPROPID_DocViewドキュメントビューobject
__VSFPROPID.VSFPROPID_FrameModeフレームのモード(ドッキング/フローティングなど)uint

DTE コマンド早見表

タブグループ操作に関係する DTE コマンドをまとめます。拡張の動的動作分岐(Command.IsAvailable)の判定にも活用できます。

コマンド動作利用例
Window.NewWindowアクティブドキュメントの複製OpenCopyOfStandardEditor の代替としても使用可能
Window.MoveToNextTabGroup右/下のタブグループへ移動本記事のメイン
Window.MoveToPreviousTabGroup左/上のタブグループへ移動左右逆方向の移動に
Window.NewVerticalTabGroup縦に新しいタブグループを作成隣が存在しない場合の前処理
Window.NewHorizontalTabGroup横に新しいタブグループを作成好みの分割方向に合わせる

信頼性を高める工夫(実務ノウハウ)

  • UI スレッドの厳守: DTE、IVsWindowFrameIVsUIShellOpenDocument を扱う直前に SwitchToMainThreadAsync() を必ず挟む。
  • 例外処理: ErrorHandler.ThrowOnFailure / Assumes.Present を用いて COM 戻り値とサービス取得の失敗を明示的に扱う。
  • 隣のグループ有無の分岐: Command.IsAvailable で移動コマンドの有効/無効を判定し、無効ならグループを新規作成してから再実行。
  • フォーカス制御: 複製後のウィンドウを Activate() してから移動する。こうすることで「意図したタブのみが移動」される。
  • ビュー種別の指定: デザイナ等は LOGVIEWID_Designer を指定。XAML/WinForms など混在するソリューションで挙動の差が出にくくなる。

拡張:複数グループを跨いで移動する

3 つ以上のグループが存在する場合、必要回数だけ移動コマンドを繰り返し呼び出します。

void MoveAcrossGroups(EnvDTE80.DTE2 dte, int steps, bool previous = false)
{
    string cmd = previous ? "Window.MoveToPreviousTabGroup" : "Window.MoveToNextTabGroup";
    for (int i = 0; i < steps; i++)
    {
        if (!TryExec(dte, cmd)) break;
    }
}

代替アプローチ:DTE のみで完結させる

「とにかく手早く」実装したい場合、DTE コマンドの連続実行でも目的は達成できます。欠点は、複製後のウィンドウハンドル(IVsWindowFrame)を直接取得できないことです。

await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var dte = (EnvDTE80.DTE2)Package.GetGlobalService(typeof(SDTE));

dte.ExecuteCommand("Window.NewWindow");          // 複製
dte.ExecuteCommand("Window.MoveToNextTabGroup"); // 隣へ移動

UI 依存が強いため、複雑なカスタマイズ(例えば生成直後のウィンドウへタグ付与など)が必要なら前述の OpenCopyOfStandardEditor 方式が適しています。

よくあるハマりどころと対策

  • コマンドが無効(IsAvailable = false): 隣のタブグループが存在しない可能性。Window.NewVerticalTabGroup で作成後に再実行。
  • 複製が別ウィンドウとしてフロートしてしまう: VSFPROPID_FrameMode をチェック。必要に応じて IVsWindowFrame の配置を変更する。
  • デザイナの複製でコードビハインドに切り替わる: logicalViewLOGVIEWID_Designer に。
  • アクティブドキュメントが null: ソリューション エクスプローラーの選択から実行した場合など。アクティブ判定を強化するか、コンテキスト(エディタ上のコマンド)から呼ぶ。
  • UI がフリーズする: 重い処理はバックグラウンドで行い、COM や DTE の操作部分のみ UI スレッドに切り戻す。

ユーザー体験を高めるTips

  • トグル動作の提供: 「複製して右へ」だけでなく「複製して左へ」「複製のみ」を同一メニュー群で提供し、作業スタイルに合わせた選択を可能に。
  • キーボードショートカット: メニューコマンドに適切なショートカットを割り当て、タブ整理の負担を軽減。
  • 状態の可視化: ステータスバーへ簡易メッセージ(どのグループへ移したか)を表示して操作のフィードバックを即時に返す。

テスト観点チェックリスト

  • 単一/複数ソリューション、マルチプロジェクトでの挙動が同一であること。
  • 縦分割/横分割どちらのレイアウトでも正しく移動すること。
  • コード、デザイナ、プレビューなど異なる logicalView の組み合わせで動作すること。
  • 未保存ドキュメントでも複製・移動に失敗しないこと。
  • 複数モニター構成(フローティング有り)で意図しない配置にならないこと。

まとめ

Visual Studio 2022 で「ドキュメントを複製して隣のタブグループへ移動」を拡張機能から自動化するには、IVsUIShellOpenDocument.OpenCopyOfStandardEditor による複製生成と、VSFPROPID_ExtWindowObjectEnvDTE.Window)経由の DTE.ExecuteCommand("Window.MoveToNextTabGroup") の組み合わせがもっとも簡潔で堅牢です。隣のグループが無い場合は Window.NewVerticalTabGroup を併用し、UI スレッドのルールを守ることでフリーズや例外を防げます。最小実装から拡張まで、本記事のサンプルをベースにチーム標準の生産的なレイアウト操作を提供してください。

付録:完全版ユーティリティ(コピペ用)

public static class VsTabGroupUtilities
{
    public static async Task DuplicateAndMoveToNextAsync(AsyncPackage package, Guid? logicalView = null)
    {
        await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();


    var frame = await GetActiveFrameAsync(package);
    if (frame == null) return;

    var openDoc = await package.GetServiceAsync(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
    Assumes.Present(openDoc);

    Guid view = logicalView ?? VSConstants.LOGVIEWID_Primary;
    ErrorHandler.ThrowOnFailure(openDoc.OpenCopyOfStandardEditor(frame, ref view, out var clone));

    ErrorHandler.ThrowOnFailure(clone.Show());
    ErrorHandler.ThrowOnFailure(clone.GetProperty((int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object winObj));

    var win = (EnvDTE.Window)winObj;
    win.Activate();

    var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));

    // 隣へ移動。無ければグループを作成してから移動
    if (!TryExec(dte, "Window.MoveToNextTabGroup"))
    {
        TryExec(dte, "Window.NewVerticalTabGroup");
        TryExec(dte, "Window.MoveToNextTabGroup");
    }
}

public static async Task DuplicateAndMoveToPreviousAsync(AsyncPackage package, Guid? logicalView = null)
{
    await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

    var frame = await GetActiveFrameAsync(package);
    if (frame == null) return;

    var openDoc = await package.GetServiceAsync(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
    Assumes.Present(openDoc);

    Guid view = logicalView ?? VSConstants.LOGVIEWID_Primary;
    ErrorHandler.ThrowOnFailure(openDoc.OpenCopyOfStandardEditor(frame, ref view, out var clone));

    ErrorHandler.ThrowOnFailure(clone.Show());
    ErrorHandler.ThrowOnFailure(clone.GetProperty((int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object winObj));

    var win = (EnvDTE.Window)winObj;
    win.Activate();

    var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));

    if (!TryExec(dte, "Window.MoveToPreviousTabGroup"))
    {
        TryExec(dte, "Window.NewVerticalTabGroup");
        TryExec(dte, "Window.MoveToPreviousTabGroup");
    }
}

private static async Task<IVsWindowFrame> GetActiveFrameAsync(AsyncPackage package)
{
    await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

    var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
    var doc = dte?.ActiveDocument;
    if (doc == null) return null;

    IVsUIHierarchy hier;
    IVsWindowFrame frame;
    uint itemid;

    bool isOpen = VsShellUtilities.IsDocumentOpen(package, doc.FullName, Guid.Empty, out hier, out itemid, out frame);
    return isOpen ? frame : null;
}

private static bool TryExec(EnvDTE80.DTE2 d, string commandName)
{
    try
    {
        var cmd = d.Commands.Item(commandName);
        if (cmd != null && cmd.IsAvailable)
        {
            d.ExecuteCommand(commandName);
            return true;
        }
    }
    catch { }
    return false;
}


} 

運用メモ

  • キー割り当て:Tools → Options → Environment → Keyboard でコマンド ID を割り当てると効果的。
  • レイアウト保存:Visual Studio はドキュメントレイアウトを自動保存するため、低レベル API での「状態保存」は不要です。
  • プロジェクト混在環境:C++/C#/XAML/WinForms が混在する大規模ソリューションでは、ビュー GUID 指定の明示が安定化に効きます。

補足:なぜ低レベルのレイアウト API を使わないのか
DTE コマンドによる移動は IDE 標準の動作をそのまま流用するため、ウィンドウマネージャやドキュメントグループの内部状態更新・永続化を Visual Studio に委譲できます。結果として拡張側は少ないコードで堅牢に仕上がり、将来バージョンでも壊れにくくなります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次