Vim, Screen, Mac/Windows 間でクリップボードを同期する(前編)

前編

  1. 現状と問題点
  2. Vim A <-> Vim B
  3. Vim -> Screen
  4. Screen -> Vim
  5. Mac/Windows <-> Vim (ssh/plink編)

後編(予定)

  1. Mac/Windows <-> Vim (PortForward編)
  2. Vim -> Mac/Windows (inotifyを利用した履歴ファイル監視による自動転送)
  3. Windows -> Vim (ClipboardViewerを利用したクリップボード監視による自動転送)

0. 現状と問題点

コードを書くときは、以下の理由からSSHLinux自宅サーバにログインしVimを使って書くことが多いです。

  • ローカル・リモートで重い処理をしてももう一方に影響がない
  • 外出時でもファイルのダウンロードに遅い回線を使わなくてすむ
  • PC再起動時にいちいちVimを落とさなくて良い
  • Windowsはないとして、MacもいいけどLinuxで開発するのが個人的には楽

ただその際に悩ましい問題が1つあります。
それはMac/Windowsのクリップボード、Screenのpaste buffer、Vimのregisterとクリップボード的なものが3つも存在し、それぞれが独立しているため、相互のコピー&ペーストがすごく大変なことです。*1
現状どうしてるかというと…

異なるVim

id:secondlifeさん作のyanktmp.vimを利用させていただいています。
大変便利なのですが、yankが行単位でしかできないところが不便に感じていました。

Screen, Mac/Windows から Vim

そのままペーストするとインデントが入ってしまうので、

  1. :set paste
  2. ペースト
  3. :set nopaste

と手順を踏む必要があります。致命的ではないですが少しめんどくさいです。
またTerminal.appではペーストする内容が大きいと途中で切れてしまうことがありました。

Vim から Screen, Mac/Windows

Screenのcopy modeやTerminal.app/PuTTY上でマウスで選択しコピーをしています。
問題点としては、

  • Mac/Windows への場合マウスが必要
  • Vimを縦分割しているとできない。新しいタブに単独で開き直してからコピーする必要がある
  • Terminal.appだと行末に本来存在しない空白が付くことがあって使い物にならない

と、ここは一番改善したいところです。


以上のような問題を解決するべく、試行錯誤した過程をこの先書き連ねたいと思います。


1. Vim A <-> Vim B

必要なもの

この章では A, B 2つのVimを起動している状態で、Vim AでyankしたものをVim Bでペーストすることを目標とします。
前出のyanktmp.vimでも行単位でのyankは可能ですが、文字単位や矩形選択にも対応したいです。そして可能であれば、Vim標準のキーマッピングでyank, ペーストがしたいです。


これを実現するためにYankRing.vimというVimプラグインを利用しました。この後の全項目でもこのプラグインが中心の役目を果たします。
YankRing.vimはyankした履歴を管理する定番のプラグインですのですでに使っている方も多いのではないでしょうか。このプラグインは以前は履歴をメモリ上に保持していたのですが、いつからか ~/yankring_history_v2.txt に保存するようになりました。このファイルはVim間で共通なので、実はYankRing.vimを使っている場合、すでにyank履歴は共有しているのです。ただし、このファイルを読み込むタイミングの問題で最後にyankした内容だけは共有できていません。これをどうにかできないかと思い、YankRing.vim内でどんな処理がされているのかを調べてみました。

まずyankを行うと次のような処理がされます。

  1. yankに使われるキーが YRMapsExpression() によって wrap されている
  2. YRMapsExpression() により入力したキーが実行された後に :yrrecord が実行される
  3. :yrrecord 内で s:YRMRUAdd() が呼ばれる
    1. s:YRMRUAdd() で s:YRHistoryRead() が呼ばれる
      1. s:YRHistoryRead() で ~/yankring_history_v2.txt の更新時刻が最後に自分が保存した時よりも新しければ内容をリスト s:yr_history_list にロードする
    2. s:YRMRUAdd() でリスト s:yr_history_list に yank した内容を追加する
    3. s:YRMRUAdd() でリスト s:YRHistorySave() が呼ばれる
      1. s:YRHistorySave() でリスト s:yr_history_list の内容を ~/yankring_history_v2.txt に書き込む

一方でペーストを行ったときは次のような処理になります。

  1. ペーストに使われるキーが :YRPaste によって wrap されている
  2. :YRPaste で s:YRPaste() が呼ばれる
    1. s:YRPaste() で s:YRGetValElemNbr() が呼ばれる
      1. s:YRGetValElemNbr() がリスト s:yr_history_list から最新のデータを返す
    2. s:YRPaste() で s:YRGetValElemNbr() から返ってきたデータを register " に格納する
    3. s:YRPaste() で入力したキーが実行される(つまり register " の内容をペーストする)

えーと、つまりペーストの一連の処理が始まる最初に s:YRHistoryLoad() を呼べば解決です。ね、簡単でしょう?

といっても実装が50行くらいにはなるため、pluginとして作成しました。
yankringsync.vim
これをダウンロードし ~/.vim/plugin に置いてください。

あとはYankRing.vimがインストールされていればこれだけで準備は完了です。Vim AでyankしてからVim Bでペーストしてみてください。Vim標準のキーマッピングで同期が出来ていることがわかると思います。


2. Vim -> Screen

必要なもの
  • このページの [1]
  • Ruby

この章ではVimでyankしたものをScreenでペーストすることを目標とします。


[1]でVimでyankした内容は ~/yankring_history_v2.txt に格納されていることがわかりました。ということはScreenでペーストキーを押下した時に、その内容を読み込んでペーストしてあげれば良いだけです。

まず、以下のコードを vim-yankring という名前で PATH の通った場所に保存します。chmod +x も忘れずに。*2

#!/usr/bin/env ruby

path = File.join(
  ENV['VIM_YANKRING_HISTORY_DIR']  || ENV['HOME'],
  ENV['VIM_YANKRING_HISTORY_FILE'] || 'yankring_history_v2.txt'
)

open(path, 'rb') do |f|
  print f.readline.sub(/,[^,]*\z/, '').tr("\002", "\n")
end

このスクリプトは ~/yankring_history_v2.txt を読み込み最後に追加された内容を標準出力に出力します。
次に、.screenrc に以下の一行を追加します。exec !!! の標準出力をScreenでペーストしたかのように振る舞うようです。

bind ] exec !!! vim-yankring

これでVimでyankした内容がScreenでペースト可能になりました。
ただし、ペーストのデフォルトキーバインディングを上書きしてしまいましたので、このままではScreenでコピーした内容がペーストできません。それは次章で一緒に解決します。


3. Screen -> Vim

必要なもの
  • このページの [2]

この章ではScreenでコピーしたものをVimでペーストすることを目標とします。


これは[2]の逆をしてあげればOKです。つまりScreenでコピーする内容が ~/yankring_history_v2.txt に追加されるようになれば良いのです。

まずは、先程の vim-yankring を、標準入力か引数にファイル名があればその内容を ~/yankring_history_v2.txt に最新のデータとして追加するように変更します。*3

ダウンロード

#!/usr/bin/env ruby

class YankRing
  DEFAULT_HISTORY_DIR  = ENV['HOME']
  DEFAULT_HISTORY_FILE = 'yankring_history_v2.txt'
  MAX_ELEMENT_LENGTH   = 2 ** 20
  HISTORY_NEWLINE      = "\002"
  FILE_BINARY          = File::BINARY rescue 0

  def initialize(path)
    @path = path
  end

  def [](index)
    raise Error unless File.file?(@path)

    open(@path, 'rb') do |f|
      f.flock(File::LOCK_SH)
      index.times { f.readline }
      decode(f.readline)
    end
  rescue EOFError
    raise Error
  end

  def first
    self[0]
  end

  def insert(index, data)
    line = encode(data)

    open(@path, File::RDWR | File::CREAT | FILE_BINARY) do |f|
      f.flock(File::LOCK_EX)
      lines = f.readlines
      next if line == lines[index]
      lines.insert([index, lines.length].min, line)
      f.rewind
      f.write(lines.join)
    end
  end

  def unshift(data)
    insert(0, data)
  end

  private
    def decode(line)
      line.sub(/,[^,]*\z/, '').tr(HISTORY_NEWLINE, "\n")
    end

    def encode(data)
      line  = MAX_ELEMENT_LENGTH == 0 ? data.dup : data[0, MAX_ELEMENT_LENGTH]
      multi = line.gsub!("\r\n", HISTORY_NEWLINE)
      multi = line.tr!("\n\r", HISTORY_NEWLINE) || multi
      line << (multi ? ",V\n" : ",v\n")
    end

  class Error < StandardError; end
end


if $0 == __FILE__
  begin
    yankring = YankRing.new(File.join(
      ENV['VIM_YANKRING_HISTORY_DIR']  || YankRing::DEFAULT_HISTORY_DIR,
      ENV['VIM_YANKRING_HISTORY_FILE'] || YankRing::DEFAULT_HISTORY_FILE
    ))

    if ARGV.empty? && $stdin.tty?
      print yankring.first
    else
      yankring.unshift(ARGF.read)
    end

  rescue YankRing::Error
    exit 1
  end
end

次に、.screenrc に以下を追加します。

bufferfile $HOME/.screen_paste_buffer
bindkey -m ^M  eval 'stuff \015' writebuf '!vim-yankring $HOME/.screen_paste_buffer'
bindkey -m ' ' eval 'stuff \040' writebuf '!vim-yankring $HOME/.screen_paste_buffer'
bindkey -m W   eval 'stuff W'    writebuf '!vim-yankring $HOME/.screen_paste_buffer'
bindkey -m Y   eval 'stuff Y'    writebuf '!vim-yankring $HOME/.screen_paste_buffer'
bindkey -m y   eval 'stuff y'    writebuf '!vim-yankring $HOME/.screen_paste_buffer'

bindkey -m はコピーモードのキーバインディングを変更します。*4

4行目を例に説明すると、コピーモードで W キーを押下すると、

  1. W が入力、つまりカーソル下の単語がコピーされ (stuff W)
  2. paste buffer の中身を bufferfile で設定されているファイルに書き出し (writebuf)
  3. ~/.screen_paste_buffer の内容を ~/yankring_history_v2.txt に追加 (!vim-yankring $HOME/.screen_paste_buffer)

となります。*5

1行目の bufferfile はなくても動くのですが、デフォルトが /tmp/screen-exchange なのでこの用途であればホームディレクトリ以下に移すのが良いでしょう。

これでScreenでコピーした内容がVimでペースト可能になりました。
[2]でScreenのpaste bufferの内容がペーストできなくなってしまっていましたが、これでpaste bufferと ~/yankring_history_v2.txt が同期するようになりましたのでその問題も解決です。


4. Mac/Windows <-> Vim (ssh/plink編)

必要なもの

この章ではVimとMac/Windowsのクリップボードを、自動ではなくコマンド実行することにより同期することを目標とします。


[3] で作成したvim-yankringとsshコマンドを利用すれば、SSH経由で ~/yankring_history_v2.txt の取得・登録が簡単にできます。

Mac/Cygwin上であれば

$ ssh example.com -t vim-yankring

これで標準出力にVimの最新のyank内容を出力できますし

$ echo foo | ssh example.com vim-yankring

これでfooを最新のデータとして登録できます。

またMac/Windowsのクリップボードの内容を標準出力から取得、標準入力から登録するには以下のコマンドが使えます。

Mac Windows(Cygwin)
取得 pbpaste getclip
登録 pbcopy putclip

これが基本なのですが、快適に使うためにはもう2点ほどクリアする必要があります。

パスフレーズを毎回入力しなくてすむようにする

sshでアクセスするときは基本的には毎回パスフレーズを入力する必要がありますが、同期のたびにパスフレーズを入力するというのでは流石に使い物になりません。
Macの場合は元々keychainの機能があり、sshコマンドで求められるパスフレーズも記憶できますのでそれを使えば問題ないと思います。
Windowsの場合はPuTTYが使えます。PuTTYに含まれるPageantに記憶し、sshコマンドの代わりにPlinkを使うことで毎回のパスフレーズ入力を回避できます。

マルチバイト文字でも文字化けしないようにする

~/yankring_history_v2.txt の中身はVimのencodingで設定された文字エンコーディングになります。*6
それに対してWindowsではCP932が使われているため、そのままでは文字化けしてしまいます。そのため、今回はnkfを使って文字コードの変換処理を入れました。nkfをダウンロードしPATHの通った場所に置いてください。*7


以上を踏まえて、Mac と Windows でそれぞれの同期方法をまとめます。

Mac

Mac は先程のコマンドを実行すれば良いだけです。

// Vim -> Mac
$ ssh example.com -t vim-yankring | pbcopy
// Mac -> Vim
$ pbpaste | ssh example.com vim-yankring

この2つのコマンドをランチャ等から素早く実行出来るようにしておけば良いと思います。

Windows

コマンドは以下になります。

// Vim -> Windows
$ plink.exe -ssh example.com -t vim-yankring | nkf -Ws -Lw -x | putclip
// Windows -> Vim
$ getclip | nkf -Sw -Lu -x | plink.exe -ssh example.com vim-yankring

ただWindowsではコマンド一つ実行するにも一苦労なので、上記のコマンドを実行する簡単なWSHスクリプトを書きました。

ダウンロード

<?xml version="1.0" encoding="utf-8"?>
<job><script language="JScript"><![CDATA[

var args = WScript.Arguments;

if (!(args.length >= 3 &&
      /^(get|put)$/.test(args(0)) &&
      new ActiveXObject('Scripting.FileSystemObject').FileExists(args(1)))) {
    WScript.Echo([
        'Usage:',
        '  ' + WScript.ScriptName + ' get <plink path> <plink options>',
        '  ' + WScript.ScriptName + ' put <plink path> <plink options>',
    ].join('\n'));
    WScript.Quit();
}

var command = "'" + args(1).replace(/\\/g, '/') + "'";
for (var i = 2; i < args.length; i++) {
    command += ' ' + args(i);
}

if (args(0) == 'get') {
    command += ' -t vim-yankring | nkf -Ws -Lw -x | putclip';
} else {
    command = 'getclip | nkf -Sw -Lu -x | ' + command + ' vim-yankring';
}

command  = 'sh -c "' + command + '"';
WScript.CreateObject('WScript.Shell').Run(command, 0, true);

]]></script></job>

このコードを syncclip.wsf として保存し、

syncclip.wsf <get|put> <plink path> <plink options>

で Vim -> Windows (get), Windows -> Vim (put) の同期ができます。
具体的には

syncclip.wsf get "C:\Program Files\PuTTY\plink.exe" -ssh -P 12345 foo@example.com
syncclip.wsf put "C:\Program Files\PuTTY\plink.exe" -load bar

のようなコマンドを、ショートカットにでもランチャにでも登録して実行してください。

Cygwinもnkfも入れたくないという方のために、syncclip.wsfとほぼ同じ動作のものをPython+py2exeでexe化したものを作りました。
ダウンロードソース



ここまでですでに当初の目的は達成した感があるのですが、ここまで来たら

  • すでにSSH接続をしてる状態なのに、同期のたびに新たに接続する必要があり、遅いしスマートじゃない
  • 同期のために明示的にショートカットキーを入力しないといけないのが嫌

このあたりもどうにかしたくなってきました。
後編ではさらにこれらを解決するために試行錯誤していきたいと思います。*8

*1:SSHでなくローカルで、例えばMac上でVimを使っている時は、VimとMacのクリップボードを同期する機能があるので困ることはないのですが、[2], [3] あたりは使えるかもしれません

*2:[]もし .vimrc で g:yankring_history_dir や g:yankring_history_file をカスタマイズしているときはそれにあわせて環境変数 VIM_YANKRING_HISTORY_DIR, VIM_YANKRING_HISTORY_FILE を設定してください。また[4]に対応するために、例えばZSHを使っている人であれば .zshrc ではなく .zshenv に書く必要があるので気をつけてください[]

*3:最初は標準入力だけサポートすればいいと思ったのですが、Screen の eval コマンドではリダイレクトがうまくできなかったのでファイル名引数もサポートしました

*4:この設定は少し副作用があり、どうやらコマンドラインモードでもこの設定が効いてしまうようです。そのため、コマンドラインモードの入力内容によっては、コピーが何回も実行されます。ただ大した問題ではないと思うので放置しています。

*5:本当はこの後に bufferfile を消したくて、removebuf や !rm -f $HOME/.screen_paste_buffer を追加してみたのですが、うまくいきませんでした。どなたかご存じの方は教えていただけると幸いです。

*6:おそらくほとんどの人がUTF-8だと思うので、この記事とリンク先のプログラムではUTF-8が設定されていると想定して書いています。それ以外の場合は必要に応じて修正してください。

*7:pbpaste/pbcopyでもShift_JISしか受け付けない環境もあるようですので、その場合はUTF-8が扱えるように設定しておく必要があります

*8:このためだけにデーモンを立ち上げるのはどうなの?とか、ローカルでコピーしたものが勝手に外部に飛んでいくってどうなの?とか、いろいろ問題もありますので、実用的には[4]までで十分ではないかとは思っています