こんにちわ。ゴリラです。
最近GoでシンプルなTUIファイラーを作ったので、軽く紹介して実装の話をしていきます。
以前書いたこちらの記事でも軽く紹介しています。
機能
ざっくり以下の機能を持っています。
- ディレクトリ、ファイルの作成、削除、コピー、リネーム、プレビュー
- ディレクトリのブックマーク
詳しくはREADMEを参照してください。
実装について
筆者はTUIツールを作るときに、いつもtviewというライブラリを使っています。
tview
の基本的な使い方については別途記事を書く予定なので、本記事ではそれ以外でいくつかの機能の実装について解説していきます。
ファイル一覧
ioutil.ReadDir(dirname string) ([]os.FileInfo, error)
を使ってパス配下のディレクトリ・ファイル情報を取得しています。
戻り値のos.FileInfo
は以下のようにインターフェイスになっていて、ディレクトリ及びファイルの情報を取得できます。
// A FileInfo describes a file and is returned by Stat and Lstat.
type FileInfo interface {
Name() string // base name of the file
Size() int64 // length in bytes for regular files; system-dependent for others
Mode() FileMode // file mode bits
ModTime() time.Time // modification time
IsDir() bool // abbreviation for Mode().IsDir()
Sys() interface{} // underlying data source (can return nil)
}
FileInfo
のメソッドについて解説していきます。
-
Size()
Size()
で取得できるのはバイト数になっていますが、そのままだと分かりづらいです。
そこで、サイズを人間が読みやすい形に変換するdustin/go-humanizeといライブラリを使って読みやすい形式に変換しています。
こちらのライブラリはサイズ以外にもいろんな情報を人間が読みやすい形に変換したりパースできます。詳細はREADMEを参照してください。 -
Mode()
Mode()
はファイルモードの情報os.FileMode
を返しますが、そのまま出力すると上位1bitしか表示されません。
ディレクトリならd--------
が表示されます。os.FileMode
にはString()
が生えているので、それを使うとls
コマンドでみるような形式drwxr-xr-x
が表示されます。 -
Owner
とGroup
Sys()
をsyscall.Stat_t
に型アサーションしてOwner
とGroup
を取ります。Sys()
の実態はOSによって異なります。Windowsの場合はsyscall.Win32FileAttributeData
型にアサーションします。
Stat_t
からはUid
とGid
を取得できるので、それをもとにos/user
パッケージのLookupId
とLookupGroupId
を使用してユーザIDとグループIDから名前を取得します。
// get file owner, group
if stat, ok := file.Sys().(*syscall.Stat_t); ok {
uid := strconv.Itoa(int(stat.Uid))
u, err := user.LookupId(uid)
if err != nil {
owner = uid
} else {
owner = u.Username
}
gid := strconv.Itoa(int(stat.Gid))
g, err := user.LookupGroupId(gid)
if err != nil {
group = gid
} else {
group = g.Name
}
}
もう少し詳しく知りたい方はGoならわかるシステムプログラミングを読んでみてください。
ファイル、ディレクトリの操作
ファイル、ディレクトリのリネーム、新規作成などはos
パッケージの関数を使用すれば簡単です。
操作 | 関数 |
---|---|
ファイル作成 | os.Create(name string) (*os.File, error) |
ディレクトリ作成 | os.Mkdir(name string, perm os.FileMode) error |
ファイルの削除 | os.Remove(name string) error |
ディレクトリの削除 | os.RemoveAll(path string) error |
リネーム | os.Rename(oldpath, newpath string) error |
1つ問題なのはファイル、ディレクトリのコピー関数が用意されていないことです。そもそもコピーのシステムコールがないからだと思います。
で、io.Copy
を使えば中身のコピーはできますが、属性のコピーまではできないです。面倒だったので属性のコピーも含めてやってくれるotiai10/copyというライブラリをを使いました。
ブックマーク
ff
はb
でディレクトリをブックマークしておくことができます。ブックマークはデフォルトでは無効になっているので、
以下のようにconfig.yaml
に設定を追記する必要があります。
bookmark:
enable: true
file: $XDG_CONFIG_HOME/ff/bookmark.db
このブックマークはsqlite3
のDBを使っています。ff
を起動するときにfile
に設定したファイルをDBファイルとして使います。このファイルはなくても自動で作成するようになっています。以下の実装はブックマークの初期化処理になっています。
file = os.ExpandEnv(file)
// if db file is not exist, create new db file
if !system.IsExist(file) {
if _, err := os.Create(file); err != nil {
log.Println(err)
// if can't create new file, use in memory db
file = ":memory:"
}
}
db, err := gorm.Open("sqlite3", file)
if err != nil {
log.Println(err)
return nil, err
}
db.SetLogger(DBLogger{})
db.LogMode(true)
if err := db.AutoMigrate(&Bookmark{}).Error; err != nil {
log.Println(err)
return nil, err
}
ファイルを作成できないときは:memory:
で、インメモリにしています。メモリ上に一時的にDBを作成して使います。
正直インメモリのメリットは皆無なので、ここは改善しようかなと思っています。
ちなみに、sqlite3
は面白いことに、空のファイルをDBとして使うことができます。
モックアプリを作る時にとても便利だなと思いました。
DBの操作はGORMというORMを使っています。標準のパッケージでも使おうかなと思いましたが、慣れているライブラリに頼りました。ちゃんとdatabase/sql
を勉強したい…
プレビュー
ff
は引数-preview
もしくは以下の設定をconfig.yaml
に追加することでプレビュー機能を有効にすることができます。
preview:
enable: false
# preview colorscheme. you can use colorscheme following
# https://xyproto.github.io/splash/docs/all.html
colorscheme: monokai
プレビューではファイル、ディレクトリの中身をプレビューできます。ファイルはシンタックスハイライトされます。
ただ、大きすぎるファイルを開くと固まってしまうのでプレビューしないようしています。
シンタックスハイライトはalecthomas/chromaというライブラリを使っています。
ライブラリの使い方についてはREADMEを参照してください。
GoでソースコードをANSIエスケープシーケンスでハイライトしたいときに便利です。
具体的な実装は次のようになります。chroma
はANSIの文字列を返しますが、そのままtview
に出力しても色がつかず、ANSIもそのまま出力されます。tview
でも色が表示できるようにtview.TranslateANSI()
を使う必要があります。
func (p *Preview) Highlight(entry *Entry) string {
// Determine lexer.
b, err := ioutil.ReadFile(entry.PathName)
if err != nil {
log.Println(err)
return err.Error()
}
ext := filepath.Ext(entry.Name)
l := lexers.Get(ext)
if l == nil {
l = lexers.Analyse(string(b))
}
if l == nil {
l = lexers.Fallback
}
l = chroma.Coalesce(l)
// Determine formatter.
// TODO check terminal
f := formatters.Get("terminal256")
if f == nil {
f = formatters.Fallback
}
// Determine style.
s := styles.Get(p.colorscheme)
if s == nil {
s = styles.Fallback
}
it, err := l.Tokenise(nil, string(b))
if err != nil {
log.Println(err)
return err.Error()
}
var buf bytes.Buffer
if err := f.Format(&buf, s, it); err != nil {
log.Println(err)
return err.Error()
}
return tview.TranslateANSI(buf.String())
}
ファイル編集
e
で$EDITOR
を使ってファイルを編集できますが、Vim
の場合は少し面白い動きをします。
Vim
にはターミナルの機能があり、Vim上でターミナルを開きコマンドを実行することができます。
通常のターミナルなのでその中で更にVimを立ち上げることもできます。つまりVim
in Vim
になってしまうんです。
ff
をVimのプラグインとして使う時、選択したファイルを起動中のVim
で開きたいと思う方が多いかと思います。
そこで、Vimのterminal api
機能を使って、選択したファイルを実行しているVimで開くようにします。
詳細は省きますが、Vim
のターミナル上でsh -c 'echo "\x1b]51;[\"drop\",\"main.go\"]\x07"'
を実行するとmain.go
が開きます。便利ですね。
Goの場合はexec
パッケージを使って実行します。
// if `ff` running in vim terminal, use running vim
if os.Getenv("VIM_TERMINAL") != "" && editor == "vim" {
cmd := exec.Command("sh", "-c", fmt.Sprintf(`echo '\x1b]51;["drop","%s"]\x07'`, file))
cmd.Stdout = os.Stdout
return cmd.Run()
}
何を言っているのかよくわからない方もいると思いますが、以下のgifを見てみてください。
それでもよくわからんって方はぜひ試してみてください。
コマンド実行
ff
は任意の外部コマンドを実行する事ができます。内部では単にexec.Command
を使っているだけです。
text := cmdline.GetText()
if text == "" {
return
}
cmdText := strings.Split(text, " ")
// expand environments
for i, c := range cmdText[1:] {
cmdText[i+1] = os.ExpandEnv(c)
}
cmd := exec.Command(cmdText[0], cmdText[1:]...)
buf := bytes.Buffer{}
cmd.Stderr = &buf
cmd.Stdout = &buf
if err := cmd.Run(); err == nil {
cmdline.SetText("")
}
result := strings.TrimRight(buf.String(), "\n")
if result != "" {
gui.Message(result, cmdline)
}
例えばmkdir -p a/b/c
というようにディレクトリ階層を作りたい場合はコマンドのが早いです。そういった柔軟な使い方が出来るようにしたいためコマンド機能を実装しましたが、1点問題があります。
それは標準入力、出力を使用できないことです。
それによりVim
といった標準入力を使うようなコマンドを実行するとff
が固ります。
実装側で防ぐ方法があるなら良いのですが、今のところベストな対処法は思いつかないです。
一応READMEに記述していますが、ユーザに気をつけてねっていうような機能は果たしてどうなんだろう?というモヤモヤ感は自分の中にあるので将来的にはこの機能を廃止するかもしれないです。
まとめ
ざっくりですが、実装について軽く解説しました。ファイラーを作ることで、ファイルシステムまわりについて知見を得られたので、作ってよかったと感じています。
ただ、ファイルシステムの深いところは全然わからないので(例えばなぜコピーのシステムコールがないのかなど)、そこは引き続き勉強していこうと思います。