├── demo.gif ├── icon.png ├── icons ├── help.png ├── error.png ├── forum.png ├── issue.png ├── reload.png ├── sublime.png ├── vscode.png ├── warning.png ├── settings.png ├── spinner-1.png ├── spinner-2.png ├── spinner-3.png ├── toggle-on.png ├── update-ok.png ├── toggle-off.png └── update-available.png ├── Alfred Sublime Text.afdesign ├── .github └── dependabot.yml ├── go.mod ├── modd.conf ├── .golangci.toml ├── scan_test.go ├── TODO.taskpaper ├── sublime.toml ├── filter.go ├── .gitignore ├── alfredenv.sh ├── LICENCE.txt ├── util_test.go ├── util.go ├── FinderSelection.js ├── icons.go ├── filter_test.go ├── project.go ├── main.go ├── project_test.go ├── config.go ├── go.sum ├── magefile.go ├── README.md ├── scan.go ├── cli.go └── info.plist /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/demo.gif -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icon.png -------------------------------------------------------------------------------- /icons/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/help.png -------------------------------------------------------------------------------- /icons/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/error.png -------------------------------------------------------------------------------- /icons/forum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/forum.png -------------------------------------------------------------------------------- /icons/issue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/issue.png -------------------------------------------------------------------------------- /icons/reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/reload.png -------------------------------------------------------------------------------- /icons/sublime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/sublime.png -------------------------------------------------------------------------------- /icons/vscode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/vscode.png -------------------------------------------------------------------------------- /icons/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/warning.png -------------------------------------------------------------------------------- /icons/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/settings.png -------------------------------------------------------------------------------- /icons/spinner-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/spinner-1.png -------------------------------------------------------------------------------- /icons/spinner-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/spinner-2.png -------------------------------------------------------------------------------- /icons/spinner-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/spinner-3.png -------------------------------------------------------------------------------- /icons/toggle-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/toggle-on.png -------------------------------------------------------------------------------- /icons/update-ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/update-ok.png -------------------------------------------------------------------------------- /icons/toggle-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/toggle-off.png -------------------------------------------------------------------------------- /icons/update-available.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/icons/update-available.png -------------------------------------------------------------------------------- /Alfred Sublime Text.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-sublime-text/HEAD/Alfred Sublime Text.afdesign -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: github.com/deanishe/awgo 11 | versions: 12 | - 0.27.1 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/deanishe/alfred-sublime-text 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/davecgh/go-spew v1.1.1 8 | github.com/deanishe/awgo v0.29.1 9 | github.com/gobwas/glob v0.2.3 10 | github.com/magefile/mage v1.11.0 11 | github.com/tidwall/jsonc v0.3.2 12 | ) 13 | -------------------------------------------------------------------------------- /modd.conf: -------------------------------------------------------------------------------- 1 | 2 | magefile.go 3 | magefile_*.go { 4 | prep +onchange: " 5 | # update mage 6 | mage -l 7 | " 8 | } 9 | 10 | **/*_test.go { 11 | prep +onchange: go test @dirmods 12 | } 13 | 14 | modd.conf 15 | README.* 16 | **/*.png 17 | *.js 18 | icons/*.png 19 | **/*.go 20 | !mage*.go 21 | !**/*_test.go 22 | !build/** 23 | !dist/** { 24 | prep +onchange: go test . && mage -v run 25 | } 26 | -------------------------------------------------------------------------------- /.golangci.toml: -------------------------------------------------------------------------------- 1 | [run] 2 | deadline = "5m" 3 | 4 | [linters] 5 | disable-all = true 6 | enable = [ 7 | "deadcode", 8 | "goconst", 9 | "gofmt", 10 | "gosimple", 11 | "goimports", 12 | "ineffassign", 13 | "scopelint", 14 | "staticcheck", 15 | "stylecheck", 16 | "unconvert", 17 | "unused", 18 | ] 19 | 20 | [linter-settings] 21 | [linter-settings.errcheck] 22 | check-blank = true 23 | check-type-assertions = true 24 | 25 | [issues] 26 | max-same-issues = 50 27 | max-issues-per-linter = 50 28 | -------------------------------------------------------------------------------- /scan_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-27 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "testing" 13 | "time" 14 | ) 15 | 16 | var ( 17 | testInterval = time.Second * 25 18 | testConf = &config{ 19 | FindInterval: testInterval, 20 | MDFindInterval: testInterval, 21 | LocateInterval: testInterval, 22 | } 23 | ) 24 | 25 | func TestManager(t *testing.T) { 26 | sm := NewScanManager(testConf) 27 | 28 | for _, k := range []string{"mdfind", "locate"} { 29 | 30 | if sm.intervals[k] != testInterval { 31 | t.Errorf("Bad %s interval. Expected=%v, Got=%v", k, testInterval, sm.intervals[k]) 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /TODO.taskpaper: -------------------------------------------------------------------------------- 1 | Bugs: 2 | - `mdfind` finds everything that matches ".sublime-project", not only *.sublime-project files. 3 | Ensure all results end with '.sublime-project'. 4 | Updating: 5 | - Re-jigger caching/filtering behaviour? 6 | Currently, `locate` results are purged before caching. This means that files on disks currently not connected will disappear for up to a week. Is it better to store all the results and filter them on retrieval (in `sublime.py`) or will `mdfind` likely pick them up? 7 | - Is an option to force reload from `locate` a viable alternative? 8 | Probably! 9 | Interface: 10 | - Offer a way to edit `settings.json` via Alfred? 11 | Lots of work, and anyone using Sublime Text should, nay must, be able to handle editing a JSON file. Not much of an ST user if you can't… -------------------------------------------------------------------------------- /sublime.toml: -------------------------------------------------------------------------------- 1 | # How many directories deep to search by default. 2 | # 0 = the directory itself 3 | # 1 = immediate children of the directory 4 | # 2 = grandchildren of the directory 5 | # etc. 6 | # default: 2 7 | # 8 | # depth = 2 9 | 10 | 11 | # How long to cache the list of projects for. 12 | # default: 5m 13 | # 14 | # cache-age = "5m" 15 | 16 | 17 | # git-style glob patterns of paths to ignore. 18 | # default: [] 19 | # 20 | # E.g.: 21 | # 22 | # excludes = [ 23 | # "/Applications/*", 24 | # "**/vim/undo/**", 25 | # ] 26 | 27 | # Additional paths to search with `find`. 28 | # Each search path is specified by a [[paths]] header and requires a path value. 29 | # E.g.: 30 | # 31 | # [[paths]] 32 | # path = "~/Dropbox" 33 | # 34 | # You can override the default depth: 35 | # 36 | # [[paths]] 37 | # path = "~/Code" 38 | # depth = 3 39 | 40 | -------------------------------------------------------------------------------- /filter.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-27 7 | // 8 | 9 | package main 10 | 11 | // Filterer selectively passes through strings 12 | type Filterer func(in <-chan string) <-chan string 13 | 14 | // Filter is a chain of Filterers 15 | type Filter struct { 16 | Funcs []Filterer 17 | } 18 | 19 | // Use adds a Filterer to the stack 20 | func (f *Filter) Use(fn Filterer) { 21 | f.Funcs = append(f.Funcs, fn) 22 | } 23 | 24 | // Apply runs the filter on a channel. 25 | func (f *Filter) Apply(in <-chan string) <-chan string { 26 | 27 | var out <-chan string 28 | 29 | // Make stack of handlers 30 | out = f.Funcs[len(f.Funcs)-1](in) 31 | for i := len(f.Funcs) - 2; i >= 0; i-- { 32 | out = f.Funcs[i](out) 33 | } 34 | 35 | return out 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.autoenv*.zsh 2 | # build and dist directories 3 | /build 4 | /dist 5 | 6 | # compiled binary 7 | /alfsubl 8 | 9 | # vendor stuff 10 | /vendor 11 | Gopkg.lock 12 | 13 | # Created by https://www.gitignore.io/api/python,sublimetext,vim 14 | 15 | ### SublimeText ### 16 | # cache files for sublime text 17 | *.tmlanguage.cache 18 | *.tmPreferences.cache 19 | *.stTheme.cache 20 | 21 | # workspace files are user-specific 22 | *.sublime-workspace 23 | 24 | # project files should be checked into the repository, unless a significant 25 | # proportion of contributors will probably not be using SublimeText 26 | # *.sublime-project 27 | 28 | # sftp configuration file 29 | sftp-config.json 30 | 31 | 32 | ### Vim ### 33 | # swap 34 | [._]*.s[a-w][a-z] 35 | [._]s[a-w][a-z] 36 | # session 37 | Session.vim 38 | # temporary 39 | .netrwhist 40 | *~ 41 | # auto-generated tag files 42 | tags 43 | 44 | # MacOS 45 | .DS_Store 46 | -------------------------------------------------------------------------------- /alfredenv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # When sourced, creates an Alfred-like environment needed by modd 4 | # and ./bin/build (which sources the file itself) 5 | 6 | # getvar | Read a value from info.plist 7 | getvar() { 8 | local v="$1" 9 | /usr/libexec/PlistBuddy -c "Print :$v" info.plist 10 | } 11 | 12 | # stuff in info.plist 13 | export alfred_workflow_bundleid=$( getvar "bundleid" ) 14 | export alfred_workflow_version=$( getvar "version" ) 15 | export alfred_workflow_name=$( getvar "name" ) 16 | 17 | export INTERVAL_FIND=$( getvar "variables:INTERVAL_FIND" ) 18 | export INTERVAL_MDFIND=$( getvar "variables:INTERVAL_MDFIND" ) 19 | export INTERVAL_LOCATE=$( getvar "variables:INTERVAL_LOCATE" ) 20 | 21 | # workflow data and cache directories 22 | export alfred_workflow_data="${HOME}/Library/Application Support/Alfred 3/Workflow Data/${alfred_workflow_bundleid}" 23 | export alfred_workflow_cache="${HOME}/Library/Caches/com.runningwithcrayons.Alfred-3/Workflow Data/${alfred_workflow_bundleid}" 24 | 25 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014–2018 Dean Jackson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-26 7 | // 8 | 9 | package main 10 | 11 | import "testing" 12 | 13 | func TestRelDepth(t *testing.T) { 14 | data := []struct { 15 | base, dir string 16 | depth int 17 | }{ 18 | {"", "", 0}, 19 | {".", ".", 0}, 20 | {".", ".", 0}, 21 | {"/", "/", 0}, 22 | 23 | {"/", "/dir1", 1}, 24 | {"/", "/dir1/dir2", 2}, 25 | {"/", "/dir1/dir2/dir3", 3}, 26 | {"/", "/dir1/dir2/dir3/dir4", 4}, 27 | {"/dir1", "/dir1/dir2/dir3/dir4", 3}, 28 | {"/dir1/dir2", "/dir1/dir2/dir3/dir4", 2}, 29 | {"/dir1/dir2/dir3", "/dir1/dir2/dir3/dir4", 1}, 30 | 31 | {"", "dir1", 1}, 32 | {"", "dir1/dir2", 2}, 33 | {"", "dir1/dir2/dir3", 3}, 34 | {"", "dir1/dir2/dir3/dir4", 4}, 35 | {"dir1", "dir1/dir2/dir3/dir4", 3}, 36 | {"dir1/dir2", "dir1/dir2/dir3/dir4", 2}, 37 | {"dir1/dir2/dir3", "dir1/dir2/dir3/dir4", 1}, 38 | 39 | {"/dir1", "/dir2", -1}, 40 | {"/dir1", "/", -1}, 41 | } 42 | 43 | for _, td := range data { 44 | n := reldepth(td.base, td.dir) 45 | if n != td.depth { 46 | t.Errorf("Bad Depth. Expected=%d, Got=%d, Base=%s, Dir=%s", 47 | td.depth, n, td.base, td.dir) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-26 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | ) 16 | 17 | // calculate the relative depth between base and dir. 18 | // 19 | // base itself has a depth of 0, its immediate children of 1 etc. 20 | // If dir is not under base (and is not base itself), -1 is returned. 21 | func reldepth(base, dir string) int { 22 | 23 | base = filepath.Clean(base) 24 | dir = filepath.Clean(dir) 25 | 26 | if base == "." { 27 | base = "" 28 | } 29 | if dir == "." { 30 | dir = "" 31 | } 32 | 33 | if !strings.HasPrefix(dir, base) { 34 | // log("no match: base=%s, dir=%s", base, dir) 35 | return -1 36 | } 37 | 38 | if base == dir { 39 | return 0 40 | } 41 | 42 | if strings.HasPrefix(dir, "/") { 43 | base = base[1:] 44 | dir = dir[1:] 45 | } 46 | 47 | db := len(strings.Split(base, "/")) 48 | dd := len(strings.Split(dir, "/")) 49 | 50 | if base == "" { 51 | db = 0 52 | } 53 | if dir == "" { 54 | dd = 0 55 | } 56 | 57 | return (dd - db) 58 | } 59 | 60 | // Replace ~ in a path with the home directory. 61 | func expandPath(path string) string { 62 | if strings.HasPrefix(path, "~") { 63 | path = "${HOME}" + path[1:] 64 | } 65 | 66 | return os.ExpandEnv(path) 67 | } 68 | -------------------------------------------------------------------------------- /FinderSelection.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/osascript -l JavaScript 2 | 3 | ObjC.import('stdlib') 4 | 5 | // Application bundle IDs 6 | const finderId = 'com.apple.Finder', 7 | pathFinderId = 'com.cocoatech.PathFinder' 8 | 9 | // Get environment variable 10 | function getEnv(key) { 11 | try { 12 | return $.getenv(key) 13 | } catch(e) { 14 | return null 15 | } 16 | } 17 | 18 | // Return Path Finder selection or target as POSIX paths 19 | function pathFinderPaths() { 20 | const pf = Application(pathFinderId) 21 | let selection = pf.selection() 22 | // selected files 23 | if (selection) return selection.map(pfi => pfi.posixPath()) 24 | // target of frontmost window 25 | return [pf.finderWindows[0].target.posixPath()] 26 | } 27 | 28 | // Return Finder selection or target as POSIX paths 29 | function finderPaths() { 30 | const file2Path = fi => Path(decodeURI(fi.url()).slice(7)).toString() 31 | const finder = Application(finderId) 32 | let selection = finder.selection() 33 | // selected files 34 | if (selection && selection.length) return selection.map(file2Path) 35 | // target of frontmost window 36 | return [file2Path(finder.finderWindows[0].target)] 37 | } 38 | 39 | function run() { 40 | const activeApp = getEnv('focusedapp') 41 | let paths = [] 42 | console.log(`🍻\nactiveApp=${activeApp}`) 43 | 44 | if (activeApp === pathFinderId) paths = pathFinderPaths() 45 | else paths = finderPaths() 46 | 47 | return JSON.stringify({alfredworkflow: {arg: paths}}) 48 | } -------------------------------------------------------------------------------- /icons.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | 9 | aw "github.com/deanishe/awgo" 10 | ) 11 | 12 | // Workflow icons 13 | var ( 14 | iconError = &aw.Icon{Value: "icons/error.png"} 15 | iconForum = &aw.Icon{Value: "icons/forum.png"} 16 | iconHelp = &aw.Icon{Value: "icons/help.png"} 17 | iconIssue = &aw.Icon{Value: "icons/issue.png"} 18 | iconReload = &aw.Icon{Value: "icons/reload.png"} 19 | iconOn = &aw.Icon{Value: "icons/toggle-on.png"} 20 | iconOff = &aw.Icon{Value: "icons/toggle-off.png"} 21 | iconSettings = &aw.Icon{Value: "icons/settings.png"} 22 | iconSublime = &aw.Icon{Value: "icons/sublime.png"} 23 | iconUpdateAvailable = &aw.Icon{Value: "icons/update-available.png"} 24 | iconUpdateOK = &aw.Icon{Value: "icons/update-ok.png"} 25 | iconVSCode = &aw.Icon{Value: "icons/vscode.png"} 26 | iconWarning = &aw.Icon{Value: "icons/warning.png"} 27 | spinnerIcons = []*aw.Icon{ 28 | {Value: "icons/spinner-1.png"}, 29 | {Value: "icons/spinner-2.png"}, 30 | {Value: "icons/spinner-3.png"}, 31 | } 32 | ) 33 | 34 | func init() { 35 | aw.IconError = iconError 36 | aw.IconWarning = iconWarning 37 | } 38 | 39 | // iconSpinner returns a "frame" for a spinning icon. 40 | func iconSpinner() *aw.Icon { 41 | n := wf.Config.GetInt("RELOAD_PROGRESS", 0) 42 | wf.Var("RELOAD_PROGRESS", fmt.Sprintf("%d", n+1)) 43 | return spinnerIcons[n%len(spinnerIcons)] 44 | } 45 | -------------------------------------------------------------------------------- /filter_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-27 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "path/filepath" 13 | "testing" 14 | ) 15 | 16 | func TestFilter(t *testing.T) { 17 | data := []struct { 18 | in, out []string 19 | }{ 20 | {[]string{""}, []string{}}, 21 | {[]string{"file", "file.txt"}, []string{"file.txt"}}, 22 | {[]string{"file.txt", "file.pdf"}, []string{"file.txt", "file.pdf"}}, 23 | {[]string{"file.mp4", "file.pdf"}, []string{"file.pdf"}}, 24 | } 25 | 26 | for _, td := range data { 27 | 28 | var in = make(chan string) 29 | 30 | // Generate input 31 | go func(c chan string, data []string) { 32 | 33 | for _, s := range data { 34 | if s == "" { 35 | continue 36 | } 37 | x := filepath.Ext(s) 38 | if x == ".mp4" || x == "" { 39 | continue 40 | } 41 | c <- s 42 | } 43 | close(in) 44 | }(in, td.in) 45 | 46 | f := Filter{} 47 | f.Use(func(in <-chan string) <-chan string { 48 | var out = make(chan string) 49 | 50 | go func() { 51 | defer close(out) 52 | 53 | for s := range in { 54 | out <- s 55 | } 56 | 57 | }() 58 | 59 | return out 60 | }) 61 | 62 | out := f.Apply(in) 63 | res := []string{} 64 | for s := range out { 65 | res = append(res, s) 66 | } 67 | 68 | if !strSlicesEqual(res, td.out) { 69 | t.Errorf("Bad Filter. Expected=%#v, Got=%#v", td.out, res) 70 | } 71 | } 72 | 73 | } 74 | 75 | func strSlicesEqual(s1, s2 []string) bool { 76 | if len(s1) != len(s2) { 77 | return false 78 | } 79 | 80 | for i, s := range s1 { 81 | if s != s2[i] { 82 | return false 83 | } 84 | } 85 | 86 | return true 87 | } 88 | -------------------------------------------------------------------------------- /project.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-27 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "encoding/json" 13 | "io/ioutil" 14 | "path/filepath" 15 | "strings" 16 | 17 | // Supports comments in JSON, which is required to read 18 | // Sublime Text or VS Code project files. 19 | "github.com/tidwall/jsonc" 20 | ) 21 | 22 | // Project is a Sublime Text or VS Code project. 23 | type Project struct { 24 | Path string // to project file 25 | Folders []string 26 | } 27 | 28 | // Folder returns the path of the first project folder, falling 29 | // back to the path of the folder containing the project file. 30 | func (p Project) Folder() string { 31 | if len(p.Folders) == 0 { 32 | return filepath.Dir(p.Path) 33 | } 34 | return p.Folders[0] 35 | } 36 | 37 | // Name returns the name of the project (the filename w/o extension). 38 | func (p Project) Name() string { 39 | 40 | if p.Path == "" { 41 | return "" 42 | } 43 | 44 | s, x := filepath.Base(p.Path), filepath.Ext(p.Path) 45 | if x == "" || x == "." { 46 | return s 47 | } 48 | 49 | return s[0 : len(s)-len(x)] 50 | } 51 | 52 | type sublimeProject struct { 53 | Folders []sublimeFolder `json:"folders"` 54 | } 55 | 56 | type sublimeFolder struct { 57 | Path string `json:"path"` 58 | } 59 | 60 | // NewProject reads a .sublime-project or .code-workspace file. 61 | func NewProject(path string) (Project, error) { 62 | var ( 63 | dir = filepath.Dir(path) 64 | proj = Project{Path: path} 65 | raw = sublimeProject{} 66 | data []byte 67 | err error 68 | ) 69 | 70 | if data, err = ioutil.ReadFile(path); err != nil { 71 | return proj, err 72 | } 73 | 74 | if err = json.Unmarshal(jsonc.ToJSON(data), &raw); err == nil { 75 | proj.Folders = []string{} 76 | for _, f := range raw.Folders { 77 | if p := resolvePath(dir, f.Path); p != "" { 78 | proj.Folders = append(proj.Folders, p) 79 | } 80 | } 81 | } 82 | return proj, err 83 | } 84 | 85 | func resolvePath(base, relpath string) string { 86 | if strings.HasPrefix(relpath, "/") { 87 | return relpath 88 | } 89 | if base == "" || relpath == "" { 90 | return "" 91 | } 92 | 93 | return filepath.Clean(filepath.Join(base, relpath)) 94 | } 95 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-26 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "flag" 13 | "log" 14 | "path/filepath" 15 | 16 | "github.com/davecgh/go-spew/spew" 17 | aw "github.com/deanishe/awgo" 18 | "github.com/deanishe/awgo/update" 19 | ) 20 | 21 | const ( 22 | issueTrackerURL = "https://github.com/deanishe/alfred-sublime-text/issues" 23 | forumThreadURL = "https://www.alfredforum.com/topic/4510-find-and-open-sublime-text-projects/" 24 | repo = "deanishe/alfred-sublime-text" 25 | ) 26 | 27 | var ( 28 | cacheKey = "sublime-projects.json" 29 | fileExtension = ".sublime-project" 30 | 31 | configFile string 32 | wf *aw.Workflow 33 | ) 34 | 35 | func init() { 36 | wf = aw.New(update.GitHub(repo), aw.HelpURL(issueTrackerURL)) 37 | configFile = filepath.Join(wf.DataDir(), "sublime.toml") 38 | } 39 | 40 | // workflow entry point 41 | func run() { 42 | var err error 43 | if err = cli.Parse(wf.Args()); err != nil { 44 | if err == flag.ErrHelp { 45 | return 46 | } 47 | wf.FatalError(err) 48 | } 49 | opts.Query = cli.Arg(0) 50 | 51 | // Load configuration file 52 | if err = initConfig(); err != nil { 53 | log.Printf("couldn't create config (%s): %v", configFile, err) 54 | wf.Fatal("Couldn't create config. Check log file.") 55 | } 56 | 57 | if conf, err = loadConfig(configFile); err != nil { 58 | log.Printf("couldn't read config (%s): %v", configFile, err) 59 | wf.Fatal("Couldn't read config. Check log file.") 60 | } 61 | 62 | log.Printf("%#v", opts) 63 | if wf.Debug() { 64 | log.Printf("args=%#v => %#v", wf.Args(), cli.Args()) 65 | log.Print(spew.Sdump(conf)) 66 | } 67 | 68 | // Naughtily switch globals to propagate VSCode mode 69 | if conf.VSCode { 70 | cacheKey = "vscode-projects.json" 71 | fileExtension = ".code-workspace" 72 | } 73 | 74 | if opts.SetConfig != "" { 75 | runSetConfig() 76 | } else if opts.Config { 77 | runConfig() 78 | } else if opts.Rescan { 79 | runScan() 80 | } else if opts.Open { 81 | runOpen() 82 | } else if opts.OpenFolders { 83 | runOpenFolders() 84 | } else if opts.Search { 85 | runSearch() 86 | } else { 87 | runOpenPaths() 88 | } 89 | } 90 | 91 | // wrap run() in AwGo to catch and display panics 92 | func main() { 93 | wf.Run(run) 94 | } 95 | -------------------------------------------------------------------------------- /project_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-27 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "io/ioutil" 13 | "os" 14 | "path/filepath" 15 | "testing" 16 | ) 17 | 18 | var ( 19 | testProjJS = `{ 20 | "folders": 21 | [ 22 | { 23 | "path": "/usr/local/bin" 24 | }, 25 | { 26 | "path": "/etc" 27 | }, 28 | { 29 | "path": "." 30 | } 31 | ] 32 | }` 33 | testProjPaths = []string{"/usr/local/bin", "/etc"} 34 | ) 35 | 36 | func withTestFile(data []byte, fn func(path string)) error { 37 | f, err := ioutil.TempFile("", "alfred-sublime-") 38 | if err != nil { 39 | return err 40 | } 41 | defer os.Remove(f.Name()) 42 | 43 | if _, err := f.Write(data); err != nil { 44 | return err 45 | } 46 | 47 | fn(f.Name()) 48 | 49 | return nil 50 | } 51 | 52 | func TestParseProject(t *testing.T) { 53 | err := withTestFile([]byte(testProjJS), func(path string) { 54 | 55 | dir := filepath.Dir(path) 56 | paths := make([]string, len(testProjPaths)) 57 | copy(paths, testProjPaths) 58 | paths = append(paths, dir) 59 | 60 | proj, err := NewProject(path) 61 | if err != nil { 62 | t.Fatalf("couldn't create new project: %v", err) 63 | } 64 | 65 | if proj.Path != path { 66 | t.Errorf("Bad Path. Expected=%v, Got=%v", path, proj.Path) 67 | } 68 | 69 | if len(proj.Folders) != len(paths) { 70 | t.Fatalf("Bad Folders length. Expected=%v, Got=%v", len(paths), len(proj.Folders)) 71 | } 72 | 73 | for i, s := range proj.Folders { 74 | if s != paths[i] { 75 | t.Errorf("Bad Folder. Expected=%v, Got=%v", paths[i], s) 76 | } 77 | } 78 | 79 | if s := proj.Folder(); s != paths[0] { 80 | t.Errorf("Bad Folder. Expected=%v, Got=%v", paths[0], s) 81 | } 82 | 83 | }) 84 | if err != nil { 85 | t.Fatalf("couldn't create tempfile: %v", err) 86 | } 87 | 88 | } 89 | 90 | func TestResolvePath(t *testing.T) { 91 | data := []struct { 92 | base, rel, out string 93 | }{ 94 | {"/", "home/bob", "/home/bob"}, 95 | {"/home/bob", ".", "/home/bob"}, 96 | {".", "/home/bob", "/home/bob"}, 97 | {".", "bob", "bob"}, 98 | {".", "bob/public", "bob/public"}, 99 | {"./bob", "public", "bob/public"}, 100 | {"home", "bob", "home/bob"}, 101 | {"", "", ""}, 102 | {"home", "", ""}, 103 | {"", "bob", ""}, 104 | } 105 | 106 | for _, td := range data { 107 | s := resolvePath(td.base, td.rel) 108 | if s != td.out { 109 | t.Errorf("Bad ResolvePath. Expected=%v, Got=%v", td.out, s) 110 | } 111 | } 112 | } 113 | 114 | func TestProjectNames(t *testing.T) { 115 | 116 | paths := []struct { 117 | in, out string 118 | }{ 119 | {"", ""}, 120 | {".", "."}, 121 | {"path/.", "."}, 122 | {"/", "/"}, 123 | {"~/Documents", "Documents"}, 124 | {"/Applications/Safari.app", "Safari"}, 125 | {"./Alfred Sublime.sublime-project", "Alfred Sublime"}, 126 | {"./path/to/something.txt", "something"}, 127 | } 128 | 129 | for _, td := range paths { 130 | proj := Project{Path: td.in} 131 | if proj.Name() != td.out { 132 | t.Errorf("Bad Name. Expected=%v, Got=%v", td.out, proj.Name()) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-26 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "fmt" 13 | "io/ioutil" 14 | "time" 15 | 16 | "github.com/BurntSushi/toml" 17 | "github.com/deanishe/awgo/util" 18 | ) 19 | 20 | const ( 21 | // DefaultDepth is how deep to search directories by default. 22 | // 1 means the immediate children of the specified path, 2 means 23 | // its grandchildren, etc. 24 | DefaultDepth = 2 25 | 26 | // DefaultFindInterval is how often to run find 27 | DefaultFindInterval = 5 * time.Minute 28 | 29 | // DefaultMDFindInterval is how often to run mdfind 30 | DefaultMDFindInterval = 5 * time.Minute 31 | 32 | // DefaultLocateInterval is how often to run locate 33 | DefaultLocateInterval = 24 * time.Hour 34 | 35 | defaultConfig = `# How many directories deep to search by default. 36 | # 0 = the directory itself 37 | # 1 = immediate children of the directory 38 | # 2 = grandchildren of the directory 39 | # etc. 40 | # default: 2 41 | # 42 | # depth = 2 43 | 44 | 45 | # How long to cache the list of projects for. 46 | # default: 5m 47 | # 48 | # cache-age = "5m" 49 | 50 | 51 | # git-style glob patterns of paths to ignore. 52 | # default: [] 53 | # 54 | # E.g.: 55 | # 56 | # excludes = [ 57 | # "/Applications/*", 58 | # "**/vim/undo/**", 59 | # ] 60 | 61 | # Additional paths to search with "find". 62 | # Each search path is specified by a [[paths]] header and requires a path value. 63 | # E.g.: 64 | # 65 | # [[paths]] 66 | # path = "~/Dropbox" 67 | # 68 | # You can override the default depth: 69 | # 70 | # [[paths]] 71 | # path = "~/Code" 72 | # depth = 3 73 | 74 | ` 75 | ) 76 | 77 | var conf *config 78 | 79 | func init() { 80 | conf = &config{ 81 | Depth: DefaultDepth, 82 | SearchPaths: []*searchPath{}, 83 | FindInterval: DefaultFindInterval, 84 | MDFindInterval: DefaultMDFindInterval, 85 | LocateInterval: DefaultLocateInterval, 86 | } 87 | } 88 | 89 | type config struct { 90 | // From workflow environment variables 91 | FindInterval time.Duration `toml:"-"` 92 | MDFindInterval time.Duration `toml:"-"` 93 | LocateInterval time.Duration `toml:"-"` 94 | VSCode bool `toml:"-" env:"VSCODE"` 95 | ActionProjectFile bool `toml:"-" env:"ACTION_PROJECT_FILE"` 96 | 97 | // From config file 98 | Excludes []string `toml:"excludes"` 99 | Depth int `toml:"depth"` 100 | SearchPaths []*searchPath `toml:"paths"` 101 | } 102 | 103 | type searchPath struct { 104 | Path string `toml:"path"` 105 | Excludes []string `toml:"excludes"` 106 | Depth int `toml:"depth"` 107 | } 108 | 109 | // Copy default settings file to data directory if there is no 110 | // existing settings file. 111 | func initConfig() error { 112 | if !util.PathExists(configFile) { 113 | if err := ioutil.WriteFile(configFile, []byte(defaultConfig), 0600); err != nil { 114 | return fmt.Errorf("write config: %w", err) 115 | } 116 | } 117 | return nil 118 | } 119 | 120 | // Load configuration file. 121 | func loadConfig(path string) (*config, error) { 122 | 123 | defer util.Timed(time.Now(), "load config") 124 | 125 | data, err := ioutil.ReadFile(path) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | if err := toml.Unmarshal(data, &conf); err != nil { 131 | return nil, err 132 | } 133 | 134 | // Load workflow variables 135 | if err := wf.Config.To(conf); err != nil { 136 | return nil, err 137 | } 138 | 139 | // Update depths and expand paths 140 | if conf.Depth == 0 { 141 | conf.Depth = DefaultDepth 142 | } 143 | for i, s := range conf.Excludes { 144 | conf.Excludes[i] = expandPath(s) 145 | } 146 | for _, sp := range conf.SearchPaths { 147 | if sp.Depth == 0 { 148 | sp.Depth = conf.Depth 149 | } 150 | sp.Path = expandPath(sp.Path) 151 | for i, s := range sp.Excludes { 152 | sp.Excludes[i] = expandPath(s) 153 | } 154 | } 155 | 156 | return conf, nil 157 | } 158 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= 4 | github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/deanishe/awgo v0.29.1 h1:yKAyy0e+HR60iPxaKHhY3hdTM5GCsECpWTP79j04bHg= 10 | github.com/deanishe/awgo v0.29.1/go.mod h1:1yGF+uQfWXX99TiDfAYYKjJpHTq5lHEmvHFEVCHo6KA= 11 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 12 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 13 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 14 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 15 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 16 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 17 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 18 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 19 | github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 20 | github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= 21 | github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 22 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 23 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 28 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 29 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 30 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 31 | github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= 32 | github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= 33 | go.deanishe.net/env v0.5.1 h1:WiOncK5uJj8Um57Vj2dc1bq1lMN7fgRag9up7I3LZy0= 34 | go.deanishe.net/env v0.5.1/go.mod h1:ihEYfDm0K0hq3f5ACTCQDrMTWxH9fTiA1lh1i0aMqm0= 35 | go.deanishe.net/fuzzy v1.0.0 h1:3Qp6PCX0DLb9z03b5OHwAGsbRSkgJpSLncsiDdXDt4Y= 36 | go.deanishe.net/fuzzy v1.0.0/go.mod h1:2yEEMfG7jWgT1s5EO0TteVWmx2MXFBRMr5cMm84bQNY= 37 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 38 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 39 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 40 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 43 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 44 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 45 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 46 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 47 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 48 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 49 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 50 | howett.net/plist v0.0.0-20201203080718-1454fab16a06 h1:QDxUo/w2COstK1wIBYpzQlHX/NqaQTcf9jyz347nI58= 51 | howett.net/plist v0.0.0-20201203080718-1454fab16a06/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= 52 | -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | // +build mage 5 | 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | 13 | "github.com/deanishe/awgo/util/build" 14 | "github.com/magefile/mage/mg" 15 | "github.com/magefile/mage/sh" 16 | ) 17 | 18 | // Default target to run when none is specified 19 | // If not set, running mage will list available targets 20 | // var Default = Build 21 | 22 | const ( 23 | buildDir = "./build" 24 | distDir = "./dist" 25 | ) 26 | 27 | var ( 28 | info *build.Info 29 | workDir string 30 | archs = []string{"amd64", "arm64"} 31 | codeSignID = os.Getenv("CODESIGN_ID") 32 | ) 33 | 34 | func init() { 35 | var err error 36 | if info, err = build.NewInfo(); err != nil { 37 | panic(err) 38 | } 39 | if workDir, err = os.Getwd(); err != nil { 40 | panic(err) 41 | } 42 | } 43 | 44 | func mod(args ...string) error { 45 | argv := append([]string{"mod"}, args...) 46 | return sh.RunWith(info.Env(), "go", argv...) 47 | } 48 | 49 | // Aliases are mage command aliases. 50 | var Aliases = map[string]interface{}{ 51 | "b": Build, 52 | "c": Clean, 53 | "d": Dist, 54 | "l": Link, 55 | } 56 | 57 | // make workflow in build directory 58 | func Build() error { 59 | mg.Deps(cleanBuild) 60 | fmt.Println("building ...") 61 | 62 | var bins []string 63 | for _, arch := range archs { 64 | env := info.Env() 65 | env["GOOS"] = "darwin" 66 | env["GOARCH"] = arch 67 | bin := fmt.Sprintf("%s/alfred-sublime.%s", buildDir, arch) 68 | bins = append(bins, bin) 69 | if err := sh.RunWith(env, "go", "build", "-o", bin, "."); err != nil { 70 | return err 71 | } 72 | } 73 | 74 | // build fat binary 75 | args := []string{"-create", "-output", filepath.Join(buildDir, "alfred-sublime")} 76 | if err := sh.Run("/usr/bin/lipo", append(args, bins...)...); err != nil { 77 | return err 78 | } 79 | // delete arch-specific binaries 80 | for _, bin := range bins { 81 | if err := sh.Rm(bin); err != nil { 82 | return err 83 | } 84 | } 85 | 86 | // files to include in workflow 87 | globs := build.Globs( 88 | "*.js", 89 | "*.png", 90 | "info.plist", 91 | "*.html", 92 | "README.md", 93 | "LICENCE.txt", 94 | "icons/*.png", 95 | ) 96 | 97 | return build.SymlinkGlobs(buildDir, globs...) 98 | } 99 | 100 | // run workflow 101 | func Run() error { 102 | mg.Deps(Build) 103 | fmt.Println("running ...") 104 | if err := os.Chdir("./build"); err != nil { 105 | return err 106 | } 107 | defer os.Chdir(workDir) 108 | 109 | return sh.RunWith(info.Env(), "./alfred-sublime", "-h") 110 | } 111 | 112 | func codeSign() error { 113 | if codeSignID == "" { 114 | fmt.Println("skipping signing: CODESIGN_ID unset") 115 | return nil 116 | } 117 | return sh.Run("codesign", "-f", "-s", codeSignID, "-i", info.BundleID, filepath.Join(buildDir, "alfred-sublime")) 118 | } 119 | 120 | // create an .alfredworkflow file in ./dist 121 | func Dist() error { 122 | mg.SerialDeps(Clean, Build, codeSign) 123 | p, err := build.Export(buildDir, distDir) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | fmt.Printf("built workflow file %s\n", p) 129 | return nil 130 | } 131 | 132 | // symlink build directory to Alfred's workflow directory 133 | func Link() error { 134 | mg.Deps(Build) 135 | 136 | fmt.Printf("linking %s to workflow directory ...\n", buildDir) 137 | target := filepath.Join(info.AlfredWorkflowDir, info.BundleID) 138 | 139 | if exists(target) { 140 | fmt.Println("removing existing workflow ...") 141 | } 142 | // try to remove it anyway, as dangling symlinks register as existing 143 | if err := os.RemoveAll(target); err != nil && !os.IsNotExist(err) { 144 | return err 145 | } 146 | 147 | src, err := filepath.Abs(buildDir) 148 | if err != nil { 149 | return err 150 | } 151 | return build.Symlink(target, src, true) 152 | } 153 | 154 | // download dependencies 155 | func Deps() error { 156 | mg.Deps(cleanDeps) 157 | fmt.Println("downloading deps ...") 158 | return mod("download") 159 | } 160 | 161 | func cleanDeps() error { return mod("tidy", "-v") } 162 | 163 | // remove build files 164 | func Clean() { mg.Deps(cleanBuild, cleanMage) } 165 | 166 | func cleanBuild() error { 167 | fmt.Printf("cleaning %s ...\n", buildDir) 168 | if err := sh.Rm(buildDir); err != nil { 169 | return err 170 | } 171 | return os.MkdirAll(buildDir, 0755) 172 | } 173 | 174 | func cleanMage() error { 175 | fmt.Println("cleaning mage ...") 176 | return sh.Run("mage", "-clean") 177 | } 178 | 179 | // return true if path exists 180 | func exists(path string) bool { 181 | if _, err := os.Stat(path); err != nil { 182 | if os.IsNotExist(err) { 183 | return false 184 | } 185 | panic(err) 186 | } 187 | 188 | return true 189 | } 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 | Sublime Text Projects Alfred Workflow 7 | ===================================== 8 | 9 | View, filter and open your Sublime Text (or VSCode) project files. 10 | 11 | ![][demo] 12 | 13 | 14 | 15 | - [Download & Installation](#download--installation) 16 | - [Catalina and later](#catalina-and-later) 17 | - [Usage](#usage) 18 | - [Universal Actions](#universal-actions) 19 | - [Hotkeys](#hotkeys) 20 | - [External Triggers](#external-triggers) 21 | - [How it works](#how-it-works) 22 | - [Configuration](#configuration) 23 | - [Licensing, thanks](#licensing-thanks) 24 | 25 | 26 | 27 | 28 | 29 | Download & Installation 30 | ----------------------- 31 | 32 | Download the workflow from [GitHub][gh-releases] and install by double-clicking the `Sublime-Text-Projects-X.X.X.alfredworkflow` file. 33 | 34 | 35 | 36 | ### Catalina and later 37 | 38 | If you're running Catalina or later (macOS 10.15+), you'll need to [grant the workflow executable permission to run][catalina]. 39 | 40 | 41 | 42 | Usage 43 | ----- 44 | 45 | There is one keyword, `.st`, which works as follows: 46 | 47 | - `.st []` — List/filter your `.sublime-project` files 48 | + `↩` — Open result in Sublime Text 49 | + `⌘+↩` — Reveal file in Finder 50 | - `.st rescan` — Reload cached list of projects 51 | - `.st config` — Show the current settings 52 | - `Workflow Is Up To Date` / `Workflow Update Available` — Install update or check for update 53 | - `Rescan Projects` — Reload list of projects 54 | - `Edit Config File` — Open workflow's configuration file 55 | - `Editor: Sublime Text` / `Editor: VS Code` — Which editor is selected 56 | - `Action Project File` — Whether copying/actioning a search result should use the path of the project file instead of that of the first project directory 57 | - `View Help File` — Open README in your browser 58 | - `Report Issue` — Open GitHub issue tracker in your browser 59 | - `Visit Forum Thread` — Open workflow's thread on [alfredforum.com][forum] 60 | 61 | You can enter `search` or `config` as a search query anywhere to jump to the corresponding screen. 62 | 63 | 64 | 65 | ### Universal Actions 66 | 67 | There are Universal Actions for files, URLs and text. Files are opened, and text is inserted into a new document. 68 | 69 | Multiple URLs are treated as text, but a single URL is retrieved with curl and a new document is created with its contents. 70 | 71 | 72 | 73 | ### Hotkeys 74 | 75 | The workflow has two Hotkeys (marked red) that you can set to open the currently-selected files in any application in Sublime Text. One Hotkey is for Finder and Path Finder only, and the other is for all other applications. You should set them to the same keyboard shortcut. 76 | 77 | The Finder/Path Finder variant doesn't rely on Alfred's "Selection in macOS" feature, and will open the frontmost window's target (the folder whose contents it's showing) if nothing is selected. 78 | 79 | 80 | 81 | ### External Triggers 82 | 83 | The workflow has the following External Triggers that can be used from scripts or other workflows: 84 | 85 | | Name | Description | 86 | |------------|--------------------------------------------------------| 87 | | `new` | Create a new document containing the given text | 88 | | `open` | Open the specified path in Sublime Text | 89 | | `open-url` | Create a new document with the data retrieved from URL | 90 | | `search` | Show project search results for given query | 91 | 92 | 93 | 94 | How it works 95 | ------------ 96 | 97 | The workflow scans your system for `.sublime-project` (or `.code-workspace`) files using `locate`, `mdfind` and (optionally) `find`. It then caches the list of projects for 10 minutes (by default). 98 | 99 | As the `locate` database isn't enabled on most machines (and isn't updated frequently in any case), and `mdfind` ignores hidden directories, there is an additional, optional `find`-based scanner to "fill the gaps", which you must specifically configure (see below). 100 | 101 | **NOTE**: When the workflow is asked to open a directory (e.g. via External Trigger or Universal Action), it looks for a project file in the directory, and opens that instead if one is found. 102 | 103 | 104 | 105 | Configuration 106 | ------------- 107 | 108 | Scan intervals are configured in the [workflow's configuration sheet in Alfred Preferences][confsheet]: 109 | 110 | | Variable | Type | Usage | 111 | |-----------------------|----------|----------------------------------------------------------| 112 | | `INTERVAL_FIND` | `duration` | How long to cache `find` search results for | 113 | | `INTERVAL_LOCATE` | `duration` | How long to cache `locate` search results for | 114 | | `INTERVAL_MDFIND` | `duration` | How long to cache `mdfind` search results for | 115 | | `ACTION_PROJECT_FILE` | `boolean` | Copying/actioning a search result uses project file path | 116 | | `VSCODE` | `boolean` | Switch to Visual Studio Code mode | 117 | 118 | `duration` values should be of the form `10m` or `2h`. Set to `0` to disable a particular scanner. 119 | `boolean` values should be of the form `true` and `false` or `1` and `0`. 120 | 121 | The workflow should work "out of the box", but if you have project files in directories that `mdfind` doesn't see (hidden directories, network shares), you may have to explicitly add some search paths to the `sublime.toml` configuration file in the workflow's data directory. The file is created on first run, and you can use `.st config > Workflow Settings > Edit Config File` to open it. 122 | 123 | These directories are searched with `find`. 124 | 125 | You can also add glob patterns to the `excludes` list in the settings file to ignore certain results. Excludes apply to all scanners. 126 | 127 | The options are documented in the settings file itself. 128 | 129 | 130 | 131 | Licensing, thanks 132 | ----------------- 133 | 134 | All the code is released under the [MIT Licence][mit]. 135 | 136 | The workflow is based on the [AwGo workflow library][awgo], also released under the [MIT Licence][mit]. 137 | 138 | The icons are based on [Font Awesome][awesome] and [Material Design Icons][matcom]. 139 | 140 | [forum]: https://www.alfredforum.com 141 | [awgo]: https://github.com/deanishe/awgo 142 | [awesome]: https://fontawesome.com 143 | [matcom]: https://materialdesignicons.com/ 144 | [demo]: https://raw.githubusercontent.com/deanishe/alfred-sublime-text/master/demo.gif 145 | [gh-releases]: https://github.com/deanishe/alfred-sublime-text/releases/latest 146 | [mit]: http://opensource.org/licenses/MIT 147 | [confsheet]: https://www.alfredapp.com/help/workflows/advanced/variables/#environment 148 | [catalina]: https://github.com/deanishe/awgo/wiki/Catalina 149 | -------------------------------------------------------------------------------- /scan.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-26 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "bufio" 13 | "bytes" 14 | "fmt" 15 | "log" 16 | "os" 17 | "os/exec" 18 | "sort" 19 | "strings" 20 | "sync" 21 | "time" 22 | 23 | "github.com/deanishe/awgo/util" 24 | "github.com/gobwas/glob" 25 | ) 26 | 27 | var ( 28 | // locateDBPath = "/var/db/locate.database" 29 | scanners = map[string]Scanner{ 30 | "find": &findScanner{}, 31 | "mdfind": &mdfindScanner{}, 32 | "locate": &locateScanner{}, 33 | } 34 | ) 35 | 36 | // Scanner finds Sublime Text project files. 37 | type Scanner interface { 38 | Name() string // name of scanner 39 | Scan(conf *config) (<-chan string, error) // scan for projects 40 | } 41 | 42 | // ScanManager loads and runs Scanners. 43 | type ScanManager struct { 44 | conf *config 45 | Scanners map[string]Scanner 46 | intervals map[string]time.Duration 47 | } 48 | 49 | // NewScanManager initialises a ScanManager. 50 | func NewScanManager(conf *config) *ScanManager { 51 | sm := &ScanManager{ 52 | conf: conf, 53 | Scanners: map[string]Scanner{}, 54 | intervals: map[string]time.Duration{}, 55 | } 56 | 57 | for name, sc := range scanners { 58 | var d time.Duration 59 | switch name { 60 | case "mdfind": 61 | d = conf.MDFindInterval 62 | case "locate": 63 | d = conf.LocateInterval 64 | case "find": 65 | d = conf.FindInterval 66 | default: 67 | log.Printf("[scan] unknown scanner: %s", name) 68 | d = conf.FindInterval 69 | } 70 | sm.Scanners[name] = sc 71 | sm.intervals[name] = d 72 | } 73 | 74 | return sm 75 | } 76 | 77 | // ScanDue returns true if one or more scanners needs updating. 78 | func (sm *ScanManager) ScanDue() bool { 79 | if !wf.Cache.Exists(cacheKey) { 80 | return true 81 | } 82 | if len(sm.dueScanners()) > 0 { 83 | log.Printf("[scan] cache expired") 84 | return true 85 | } 86 | return false 87 | } 88 | 89 | // Scan updates the cached lists of projects. 90 | func (sm *ScanManager) Scan() error { 91 | var ( 92 | due = map[string]bool{} 93 | ins []<-chan string 94 | out <-chan Project 95 | projs []Project 96 | f = &Filter{} 97 | ) 98 | 99 | for _, name := range sm.dueScanners() { 100 | due[name] = true 101 | } 102 | 103 | for name := range sm.Scanners { 104 | if !sm.IsActive(name) { 105 | // Clear any cached results 106 | if err := wf.Cache.Store(sm.cacheName(name), nil); err != nil { 107 | log.Printf("[scan] error clearing cache: %s", err) 108 | } 109 | log.Printf("[%s] inactive", name) 110 | continue 111 | } 112 | 113 | if due[name] { 114 | sc := sm.Scanners[name] 115 | if c, err := sc.Scan(sm.conf); err == nil { 116 | log.Printf("[%s] reloading ...", name) 117 | ins = append(ins, cacheProjects(sm.cacheName(name), c)) 118 | } else { 119 | log.Printf("[%s] error: %v", name, err) 120 | } 121 | } else { 122 | log.Printf("[%s] loading from cache ...", name) 123 | ins = append(ins, sm.scanFromCache(name)) 124 | } 125 | } 126 | 127 | // real programs have middleware 128 | f.Use(makeFilterExcludes(conf.Excludes)) 129 | f.Use(filterNotExist) 130 | f.Use(filterDupes) 131 | f.Use(filterNotProject) 132 | 133 | out = resultToProject(f.Apply(merge(ins...))) 134 | 135 | for proj := range out { 136 | log.Printf("[scan] project: %s (%s)", proj.Name(), util.PrettyPath(proj.Path)) 137 | projs = append(projs, proj) 138 | } 139 | 140 | log.Printf("%d total project(s) found", len(projs)) 141 | 142 | return wf.Cache.StoreJSON(cacheKey, projs) 143 | } 144 | 145 | // IsActive returns true if a scanner exists and is active. 146 | func (sm *ScanManager) IsActive(name string) bool { 147 | _, ok := sm.Scanners[name] 148 | if !ok { 149 | return false 150 | } 151 | return sm.intervals[name] != 0 152 | } 153 | 154 | // IsDue returns true if a scanner is active and due. 155 | func (sm *ScanManager) IsDue(name string) bool { 156 | if !sm.IsActive(name) { 157 | return false 158 | } 159 | 160 | return wf.Cache.Expired(sm.cacheName(name), sm.intervals[name]) 161 | } 162 | 163 | // load data from cache. 164 | func (sm *ScanManager) scanFromCache(name string) <-chan string { 165 | var ( 166 | key = sm.cacheName(name) 167 | out = make(chan string) 168 | ) 169 | 170 | go func() { 171 | defer close(out) 172 | defer util.Timed(time.Now(), fmt.Sprintf(`[cache] loaded "%s"`, name)) 173 | 174 | if !wf.Cache.Exists(key) { 175 | return 176 | } 177 | 178 | data, err := wf.Cache.Load(key) 179 | if err != nil { 180 | log.Printf("[scan] error reading cache: %v", err) 181 | return 182 | } 183 | 184 | scanner := bufio.NewScanner(bytes.NewReader(data)) 185 | for scanner.Scan() { 186 | out <- scanner.Text() 187 | } 188 | if err := scanner.Err(); err != nil { 189 | log.Printf("[scan] error reading cache: %v", err) 190 | } 191 | }() 192 | 193 | return out 194 | } 195 | 196 | func (sm *ScanManager) dueScanners() []string { 197 | var ( 198 | due []string 199 | force bool 200 | ) 201 | 202 | if !wf.Cache.Exists(cacheKey) { 203 | force = true 204 | } 205 | 206 | if age, err := wf.Cache.Age(cacheKey); err == nil { 207 | if fi, err := os.Stat(configFile); err == nil { 208 | if time.Since(fi.ModTime()) < age { 209 | log.Printf("[scan] config file has changed") 210 | force = true 211 | } 212 | } 213 | } 214 | 215 | for name := range sm.Scanners { 216 | if !sm.IsActive(name) { 217 | continue 218 | } 219 | 220 | if force || sm.IsDue(name) { 221 | due = append(due, name) 222 | } 223 | } 224 | return due 225 | } 226 | 227 | func (sm *ScanManager) cacheName(name string) string { 228 | prefix := "sublime-" 229 | if conf.VSCode { 230 | prefix = "vscode-" 231 | } 232 | return prefix + "projects-" + name + ".txt" 233 | } 234 | 235 | // Load loads cached Projects. 236 | func (sm *ScanManager) Load() (projects []Project, err error) { 237 | if wf.Cache.Exists(cacheKey) { 238 | err = wf.Cache.LoadJSON(cacheKey, &projects) 239 | } 240 | return 241 | } 242 | 243 | // Find files with `mdfind` 244 | type mdfindScanner struct{} 245 | 246 | func (s *mdfindScanner) Name() string { return "mdfind" } 247 | func (s *mdfindScanner) Scan(conf *config) (<-chan string, error) { 248 | cmd := exec.Command("/usr/bin/mdfind", fmt.Sprintf("kMDItemFSName == '*%s'", fileExtension)) 249 | return lineCommand(cmd, "mdfind") 250 | } 251 | 252 | // Find files with `locate` 253 | type locateScanner struct{} 254 | 255 | func (s *locateScanner) Name() string { return "locate" } 256 | func (s *locateScanner) Scan(conf *config) (<-chan string, error) { 257 | cmd := exec.Command("/usr/bin/locate", "*"+fileExtension) 258 | return lineCommand(cmd, "locate") 259 | } 260 | 261 | // Find files with `find` 262 | type findScanner struct{} 263 | 264 | func (s *findScanner) Name() string { return "find" } 265 | func (s *findScanner) Scan(conf *config) (<-chan string, error) { 266 | 267 | var chs []<-chan string 268 | for _, sp := range conf.SearchPaths { 269 | argv := []string{sp.Path, "-maxdepth", fmt.Sprintf("%d", sp.Depth)} 270 | argv = append(argv, "-type", "f", "-name", "*"+fileExtension) 271 | ch, err := lineCommand(exec.Command("/usr/bin/find", argv...), "[find] "+sp.Path) 272 | if err != nil { 273 | return nil, err 274 | } 275 | chs = append(chs, ch) 276 | } 277 | 278 | return merge(chs...), nil 279 | } 280 | 281 | // Run a command and write the lines of its output to a channel. 282 | func lineCommand(cmd *exec.Cmd, name string) (chan string, error) { 283 | 284 | var ( 285 | out = make(chan string, 100) 286 | err error 287 | ) 288 | 289 | go func() { 290 | defer close(out) 291 | defer util.Timed(time.Now(), fmt.Sprintf("%s scan", name)) 292 | 293 | stdout, err := cmd.StdoutPipe() 294 | if err != nil { 295 | log.Printf("[%s] command failed: %v", name, err) 296 | return 297 | } 298 | if err := cmd.Start(); err != nil { 299 | log.Printf("[%s] command failed: %v", name, err) 300 | return 301 | } 302 | 303 | // Read output and send it to channel 304 | scanner := bufio.NewScanner(stdout) 305 | for scanner.Scan() { 306 | out <- scanner.Text() 307 | } 308 | if err := scanner.Err(); err != nil { 309 | log.Printf("[%s] couldn't parse output: %v", name, err) 310 | } 311 | if err != cmd.Wait() { 312 | log.Printf("[%s] command failed: %v", name, err) 313 | } 314 | }() 315 | 316 | return out, err 317 | } 318 | 319 | func makeFilterExcludes(patterns []string) Filterer { 320 | return func(in <-chan string) <-chan string { 321 | return filterExcludes(in, patterns) 322 | } 323 | } 324 | 325 | // Filter files that match any of the glob patterns. 326 | func filterExcludes(in <-chan string, patterns []string) <-chan string { 327 | var globs []glob.Glob 328 | 329 | // Compile patterns 330 | for _, s := range patterns { 331 | s = expandPath(s) 332 | if g, err := glob.Compile(s); err == nil { 333 | globs = append(globs, g) 334 | } else { 335 | log.Printf("[filter] invalid pattern (%s): %v", s, err) 336 | } 337 | } 338 | 339 | return filterMatches(in, func(r string) bool { 340 | for _, g := range globs { 341 | if g.Match(r) { 342 | log.Printf("[filter] ignored (%v): %s", g, util.PrettyPath(r)) 343 | return true 344 | } 345 | } 346 | return false 347 | }) 348 | } 349 | 350 | func filterNotProject(in <-chan string) <-chan string { 351 | return filterMatches(in, func(r string) bool { 352 | return !strings.HasSuffix(r, fileExtension) 353 | }) 354 | } 355 | 356 | // Filter files that don't exist. 357 | func filterNotExist(in <-chan string) <-chan string { 358 | return filterMatches(in, func(r string) bool { 359 | if _, err := os.Stat(r); err != nil { 360 | return true 361 | } 362 | return false 363 | }) 364 | } 365 | 366 | // Filter files that have already passed through. 367 | func filterDupes(in <-chan string) <-chan string { 368 | seen := map[string]bool{} 369 | return filterMatches(in, func(r string) bool { 370 | if seen[r] { 371 | return true 372 | } 373 | seen[r] = true 374 | return false 375 | }) 376 | } 377 | 378 | // passes through paths from in to out, ignoring those for which ignore(path) returns true. 379 | func filterMatches(in <-chan string, ignore func(r string) bool) <-chan string { 380 | var out = make(chan string) 381 | go func() { 382 | defer close(out) 383 | for p := range in { 384 | if ignore(p) { 385 | continue 386 | } 387 | out <- p 388 | } 389 | }() 390 | 391 | return out 392 | } 393 | 394 | func cacheProjects(key string, in <-chan string) <-chan string { 395 | 396 | var ( 397 | projs = []string{} 398 | out = make(chan string) 399 | ) 400 | 401 | go func() { 402 | defer close(out) 403 | for p := range in { 404 | projs = append(projs, p) 405 | out <- p 406 | } 407 | 408 | sort.Strings(sort.StringSlice(projs)) 409 | data := []byte(strings.Join(projs, "\n")) 410 | if err := wf.Cache.Store(key, data); err != nil { 411 | log.Printf("[cache] error storing %s: %v", key, err) 412 | } else { 413 | log.Printf("[cache] saved %d project(s) to %s", len(projs), key) 414 | } 415 | }() 416 | 417 | return out 418 | } 419 | 420 | // Read Sublime/VSCode project files 421 | func resultToProject(in <-chan string) <-chan Project { 422 | var out = make(chan Project) 423 | 424 | go func() { 425 | defer close(out) 426 | for p := range in { 427 | proj, err := NewProject(p) 428 | if err != nil { 429 | log.Printf("[scan] couldn't read project file (%s): %v", p, err) 430 | continue 431 | } 432 | out <- proj 433 | } 434 | }() 435 | 436 | return out 437 | } 438 | 439 | // Combine the output of multiple channels into one. 440 | func merge(ins ...<-chan string) <-chan string { 441 | var ( 442 | wg sync.WaitGroup 443 | out = make(chan string) 444 | ) 445 | 446 | wg.Add(len(ins)) 447 | for _, in := range ins { 448 | go func(in <-chan string) { 449 | defer wg.Done() 450 | for p := range in { 451 | out <- p 452 | } 453 | }(in) 454 | } 455 | 456 | go func() { 457 | wg.Wait() 458 | close(out) 459 | }() 460 | 461 | return out 462 | } 463 | -------------------------------------------------------------------------------- /cli.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2018 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2018-01-27 7 | // 8 | 9 | package main 10 | 11 | import ( 12 | "flag" 13 | "fmt" 14 | "log" 15 | "os" 16 | "os/exec" 17 | "path/filepath" 18 | "strings" 19 | "time" 20 | 21 | aw "github.com/deanishe/awgo" 22 | "github.com/deanishe/awgo/util" 23 | ) 24 | 25 | var ( 26 | opts = &options{} 27 | cli = flag.NewFlagSet("alfred-sublime", flag.ContinueOnError) 28 | 29 | // Candidate paths to `subl` command-line program. We'll open projects 30 | // via `subl` because it correctly loads the workspace. Opening a 31 | // project with "Sublime Text.app" doesn't. 32 | sublPaths = []string{ 33 | "/usr/local/bin/subl", 34 | "/Applications/Sublime Text 4.app/Contents/SharedSupport/bin/subl", 35 | "/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl", 36 | } 37 | // Candidate paths to `code` command-line program. 38 | codePaths = []string{ 39 | "/usr/local/bin/code", 40 | "/Applications/VSCodium.app/Contents/Resources/app/bin/code", 41 | "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", 42 | } 43 | ) 44 | 45 | // CLI flags 46 | type options struct { 47 | // Commands 48 | Search bool 49 | Config bool 50 | Ignore bool 51 | Open bool 52 | OpenFolders bool 53 | Rescan bool 54 | SetConfig string 55 | 56 | // Options 57 | Force bool 58 | 59 | // Arguments 60 | Query string 61 | } 62 | 63 | func init() { 64 | cli.BoolVar(&opts.Search, "search", false, "search projects") 65 | cli.BoolVar(&opts.Config, "conf", false, "show/filter configuration") 66 | cli.BoolVar(&opts.Open, "open", false, "open specified file in default app") 67 | cli.BoolVar(&opts.OpenFolders, "folders", false, "open specified project") 68 | cli.BoolVar(&opts.Rescan, "rescan", false, "re-scan for projects") 69 | cli.BoolVar(&opts.Force, "force", false, "force rescan") 70 | cli.StringVar(&opts.SetConfig, "set", "", "set a configuration value") 71 | cli.Usage = func() { 72 | fmt.Fprint(os.Stderr, `usage: alfred-sublime [options] [arguments] 73 | 74 | Alfred workflow to show Sublime Text/VSCode projects. 75 | 76 | Usage: 77 | alfred-sublime ... 78 | alfred-sublime - 79 | alfred-sublime -search [] 80 | alfred-sublime -conf [] 81 | alfred-sublime -open 82 | alfred-sublime -folders 83 | alfred-sublime -rescan [-force] 84 | alfred-sublime -set 85 | alfred-sublime -h|-help 86 | 87 | Options: 88 | `) 89 | 90 | cli.PrintDefaults() 91 | } 92 | } 93 | 94 | func openCommand(path string) *exec.Cmd { 95 | // name, args := appArgs() 96 | // return exec.Command(name, append(args, path)...) 97 | var ( 98 | app = "Sublime Text" 99 | progs = sublPaths 100 | ) 101 | if conf.VSCode { 102 | app = "Visual Studio Code" 103 | progs = codePaths 104 | } 105 | 106 | for _, p := range progs { 107 | if util.PathExists(p) { 108 | return exec.Command(p, path) 109 | } 110 | } 111 | 112 | return exec.Command("/usr/bin/open", "-a", app, path) 113 | } 114 | 115 | // Try to open each command-line argument in turn. 116 | // If argument is a directory, search it for a project file. 117 | func runOpenPaths() { 118 | wf.Configure(aw.TextErrors(true)) 119 | 120 | for _, path := range cli.Args() { 121 | cmd := openCommand(findProject(path)) 122 | if path == "-" { 123 | cmd.Stdin = os.Stdin 124 | } 125 | 126 | log.Printf("opening %q ...", path) 127 | if _, err := util.RunCmd(cmd); err != nil { 128 | log.Printf("error opening %q: %v", path, err) 129 | } 130 | } 131 | } 132 | 133 | func findProject(dir string) string { 134 | fi, err := os.Stat(dir) 135 | if err != nil { 136 | log.Printf("error inspecting file %q: %v", dir, err) 137 | return dir 138 | } 139 | if !fi.IsDir() { 140 | return dir 141 | } 142 | files, err := os.ReadDir(dir) 143 | if err != nil { 144 | log.Printf("error reading directory %q: %v", dir, err) 145 | return dir 146 | } 147 | for _, de := range files { 148 | if de.IsDir() { 149 | continue 150 | } 151 | if strings.ToLower(filepath.Ext(de.Name())) == fileExtension { 152 | return filepath.Join(dir, de.Name()) 153 | } 154 | } 155 | return dir 156 | } 157 | 158 | // Open a project's folders 159 | func runOpenFolders() { 160 | wf.Configure(aw.TextErrors(true)) 161 | 162 | var ( 163 | sm = NewScanManager(conf) 164 | projs []Project 165 | err error 166 | ) 167 | if projs, err = sm.Load(); err != nil { 168 | wf.Fatalf("load projects: %v", err) 169 | } 170 | 171 | for _, proj := range projs { 172 | if proj.Path != opts.Query { 173 | continue 174 | } 175 | 176 | for _, path := range proj.Folders { 177 | log.Printf("opening folder %q ...", path) 178 | cmd := exec.Command("/usr/bin/open", path) 179 | if _, err := util.RunCmd(cmd); err != nil { 180 | log.Printf("error opening folder %q: %v", path, err) 181 | } 182 | } 183 | return 184 | } 185 | 186 | wf.Fatalf("no folders found for project %q", opts.Query) 187 | } 188 | 189 | // Filter configuration in Alfred 190 | func runConfig() { 191 | // prevent Alfred from re-ordering results 192 | if opts.Query == "" { 193 | wf.Configure(aw.SuppressUIDs(true)) 194 | } else { 195 | wf.Var("query", opts.Query) 196 | } 197 | 198 | log.Printf("filtering config %q ...", opts.Query) 199 | 200 | if wf.UpdateAvailable() { 201 | wf.NewItem("Workflow Update Available"). 202 | Subtitle("↩ or ⇥ to install update"). 203 | Valid(false). 204 | UID("update"). 205 | Autocomplete("workflow:update"). 206 | Icon(iconUpdateAvailable) 207 | } else { 208 | wf.NewItem("Workflow Is Up To Date"). 209 | Subtitle("↩ or ⇥ to check for update now"). 210 | Valid(false). 211 | UID("update"). 212 | Autocomplete("workflow:update"). 213 | Icon(iconUpdateOK) 214 | } 215 | 216 | wf.NewItem("Rescan Projects"). 217 | Subtitle("Rebuild cached list of projects"). 218 | Arg("-rescan", "-force"). 219 | Valid(true). 220 | UID("rescan"). 221 | Icon(iconReload). 222 | // Var("hide_alfred", "false"). 223 | Var("notification", "Reloading project list…"). 224 | Var("trigger", "config") 225 | 226 | wf.NewItem("Edit Config File"). 227 | Subtitle("Edit directories to scan"). 228 | Valid(true). 229 | Arg("-open", "--", configFile). 230 | UID("config"). 231 | Icon(iconSettings). 232 | Var("hide_alfred", "true") 233 | 234 | v := "true" 235 | editor := "Sublime Text" 236 | other := "VS Code" 237 | icon := iconSublime 238 | if conf.VSCode { 239 | v = "false" 240 | icon = iconVSCode 241 | editor, other = other, editor 242 | } 243 | wf.NewItem("Editor: "+editor). 244 | Subtitle("↩ to switch to "+other). 245 | Valid(true). 246 | Arg("-set", "VSCODE", v). 247 | Icon(icon). 248 | Var("notification", "Using "+other) 249 | 250 | v = "true" 251 | icon = iconOff 252 | if conf.ActionProjectFile { 253 | v = "false" 254 | icon = iconOn 255 | } 256 | wf.NewItem("Action Project File"). 257 | Subtitle("Action path of project file instead of first project directory"). 258 | Valid(true). 259 | Arg("-set", "ACTION_PROJECT_FILE", v). 260 | Icon(icon) 261 | 262 | wf.NewItem("View Help File"). 263 | Subtitle("Open workflow help in your browser"). 264 | Arg("-open", "README.html"). 265 | UID("help"). 266 | Valid(true). 267 | Icon(iconHelp). 268 | Var("hide_alfred", "true") 269 | 270 | wf.NewItem("Report Issue"). 271 | Subtitle("Open workflow issue tracker in your browser"). 272 | Arg("-open", issueTrackerURL). 273 | UID("issue"). 274 | Valid(true). 275 | Icon(iconIssue). 276 | Var("hide_alfred", "true") 277 | 278 | wf.NewItem("Visit Forum Thread"). 279 | Subtitle("Open workflow thread on alfredforum.com in your browser"). 280 | Arg("-open", forumThreadURL). 281 | UID("forum"). 282 | Valid(true). 283 | Icon(iconForum). 284 | Var("hide_alfred", "true") 285 | 286 | if opts.Query != "" { 287 | wf.Filter(opts.Query) 288 | addNavigationItems(opts.Query, "config", "rescan") 289 | } 290 | 291 | wf.WarnEmpty("No Matching Items", "Try a different query") 292 | wf.SendFeedback() 293 | } 294 | 295 | // Scan for projects and cache results 296 | func runScan() { 297 | wf.Configure(aw.TextErrors(true)) 298 | 299 | if opts.Force { 300 | if conf.FindInterval != 0 { 301 | conf.FindInterval = time.Nanosecond 302 | } 303 | if conf.MDFindInterval != 0 { 304 | conf.MDFindInterval = time.Nanosecond 305 | } 306 | if conf.LocateInterval != 0 { 307 | conf.LocateInterval = time.Nanosecond 308 | } 309 | } 310 | 311 | sm := NewScanManager(conf) 312 | if err := sm.Scan(); err != nil { 313 | wf.FatalError(err) 314 | } 315 | fmt.Print("Project scan completed") 316 | } 317 | 318 | // Open path/URL 319 | func runOpen() { 320 | wf.Configure(aw.TextErrors(true)) 321 | 322 | var args []string 323 | args = append(args, opts.Query) 324 | cmd := exec.Command("open", args...) 325 | if _, err := util.RunCmd(cmd); err != nil { 326 | wf.Fatalf("open %q: %v", opts.Query, err) 327 | } 328 | } 329 | 330 | // Save a config value and re-open settings view. 331 | func runSetConfig() { 332 | wf.Configure(aw.TextErrors(true)) 333 | 334 | var ( 335 | key = opts.SetConfig 336 | value = opts.Query 337 | ) 338 | if err := wf.Config.Set(key, value, false).Do(); err != nil { 339 | wf.Fatalf("set config %q to %q: %v", key, value, err) 340 | } 341 | log.Printf("set %q to %q", key, value) 342 | if err := wf.Alfred.RunTrigger("config", ""); err != nil { 343 | wf.Fatalf("run trigger config: %v", err) 344 | } 345 | } 346 | 347 | // Filter Sublime projects in Alfred 348 | func runSearch() { 349 | var ( 350 | projs []Project 351 | err error 352 | sm = NewScanManager(conf) 353 | ) 354 | 355 | if opts.Query != "" { 356 | log.Printf(`searching for "%s" ...`, opts.Query) 357 | } 358 | 359 | // Run "alfred-sublime -rescan" in background if need be 360 | if sm.ScanDue() && !wf.IsRunning("rescan") { 361 | log.Println("rescanning for projects ...") 362 | cmd := exec.Command(os.Args[0], "-rescan") 363 | if err := wf.RunInBackground("rescan", cmd); err != nil { 364 | log.Printf("error running rescan: %v", err) 365 | wf.Fatal("Error scanning for repos. See log file.") 366 | } 367 | } 368 | 369 | // Load data 370 | if projs, err = sm.Load(); err != nil { 371 | wf.FatalError(err) 372 | } 373 | 374 | if len(projs) == 0 && wf.IsRunning("rescan") { 375 | wf.Rerun(0.1) 376 | wf.NewItem("Scanning projects…"). 377 | Subtitle("Results will be available shortly"). 378 | Valid(false). 379 | Icon(iconSpinner()) 380 | 381 | wf.SendFeedback() 382 | return 383 | } 384 | 385 | icon := iconSublime 386 | if conf.VSCode { 387 | icon = iconVSCode 388 | } 389 | 390 | for _, proj := range projs { 391 | path := proj.Folder() 392 | if conf.ActionProjectFile { 393 | path = proj.Path 394 | } 395 | it := wf.NewItem(proj.Name()). 396 | Subtitle(util.PrettyPath(path)). 397 | Valid(true). 398 | // Arg("-project", "--", proj.Path). 399 | Arg(proj.Path). 400 | IsFile(true). 401 | UID(proj.Path). 402 | Copytext(path). 403 | Action(path). 404 | Icon(icon). 405 | Var("hide_alfred", "true") 406 | 407 | if len(proj.Folders) > 0 { 408 | sub := "Open Project Folder" 409 | if len(proj.Folders) > 1 { 410 | sub += "s" 411 | } 412 | it.NewModifier("cmd"). 413 | Subtitle(sub). 414 | Icon(&aw.Icon{Value: proj.Folder(), Type: "fileicon"}). 415 | Arg("-folders", proj.Path) 416 | } 417 | } 418 | 419 | if opts.Query != "" { 420 | res := wf.Filter(opts.Query) 421 | for _, r := range res { 422 | log.Printf("[search] %6.2f %#v", r.Score, r.SortKey) 423 | } 424 | addNavigationItems(opts.Query, "search") 425 | } 426 | 427 | wf.WarnEmpty("No Projects Found", "Try a different query?") 428 | wf.SendFeedback() 429 | } 430 | 431 | func addNavigationItems(query, backTo string, ignore ...string) { 432 | if len(query) < 3 { 433 | return 434 | } 435 | ignore = append(ignore, backTo) 436 | var ( 437 | items = []struct { 438 | keywords []string 439 | trigger string 440 | title string 441 | subtitle string 442 | arg []string 443 | note string 444 | icon *aw.Icon 445 | }{ 446 | { 447 | []string{"reload", "rescan"}, 448 | // Trigger doesn't exist, but we can't put the 449 | // real trigger (backTo) here yet because it's 450 | // the current action, which we want to filter out 451 | "rescan", 452 | "Rescan Projects", 453 | "Rescan disk & update cached list of projects", 454 | []string{"-rescan", "-force"}, 455 | "Reloading project list …", 456 | iconReload, 457 | }, 458 | { 459 | []string{"config", "prefs", "settings"}, 460 | "config", 461 | "Workflow Settings", 462 | "Access workflow's preferences", 463 | nil, 464 | "", 465 | iconSettings, 466 | }, 467 | { 468 | []string{"search", "projects", ".st"}, 469 | "search", 470 | "Search Projects", 471 | "Search scanned projects", 472 | nil, 473 | "", 474 | aw.IconWorkflow, 475 | }, 476 | } 477 | ) 478 | 479 | query = strings.ToLower(query) 480 | for _, conf := range items { 481 | if sliceContains(ignore, conf.trigger) { 482 | continue 483 | } 484 | for _, kw := range conf.keywords { 485 | if !strings.HasPrefix(strings.ToLower(kw), query) { 486 | continue 487 | } 488 | it := wf.NewItem(conf.title). 489 | Subtitle(conf.subtitle). 490 | Icon(conf.icon). 491 | UID("navigation-action."+conf.trigger). 492 | Valid(true). 493 | Var("trigger", conf.trigger). 494 | Var("query", "") 495 | 496 | // override non-existent "rescan" trigger 497 | if conf.trigger == "rescan" { 498 | it.Var("trigger", backTo) 499 | } 500 | 501 | if conf.arg != nil { 502 | it.Arg(conf.arg...) 503 | } 504 | 505 | if conf.note != "" { 506 | it.Var("notification", conf.note) 507 | } 508 | break 509 | } 510 | } 511 | } 512 | 513 | func sliceContains(sl []string, s string) bool { 514 | for _, v := range sl { 515 | if v == s { 516 | return true 517 | } 518 | } 519 | return false 520 | } 521 | -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | net.deanishe.alfred.sublime-text-projects 7 | connections 8 | 9 | 0BA23803-0255-4C05-9CD7-05738C9DBE24 10 | 11 | 12 | destinationuid 13 | 7143EA82-FD83-4544-93AC-3942DD1D6EC0 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | vitoclose 19 | 20 | 21 | 22 | 0D6DB001-6C1A-4973-BD3C-0CD4706096CB 23 | 24 | 25 | destinationuid 26 | 1F866CED-855C-4BA8-A031-00095FD859ED 27 | modifiers 28 | 0 29 | modifiersubtext 30 | 31 | vitoclose 32 | 33 | 34 | 35 | 1F866CED-855C-4BA8-A031-00095FD859ED 36 | 37 | 38 | destinationuid 39 | 5282A865-7975-4E03-83E0-721FD575334A 40 | modifiers 41 | 0 42 | modifiersubtext 43 | 44 | vitoclose 45 | 46 | 47 | 48 | destinationuid 49 | 703E2968-A7F7-4E94-A8D8-0D8959272616 50 | modifiers 51 | 0 52 | modifiersubtext 53 | 54 | vitoclose 55 | 56 | 57 | 58 | destinationuid 59 | 506077C9-6BF8-401D-B34D-ACAEAA975F30 60 | modifiers 61 | 0 62 | modifiersubtext 63 | 64 | vitoclose 65 | 66 | 67 | 68 | destinationuid 69 | 9EF074A1-94E0-4F66-86BF-D7B5ECC89006 70 | modifiers 71 | 0 72 | modifiersubtext 73 | 74 | vitoclose 75 | 76 | 77 | 78 | 215704C1-57CC-42F0-B541-8DF173C3A7D0 79 | 80 | 81 | destinationuid 82 | 4C6BFBB2-35EB-4F7F-8A0D-DF1D23AA5F82 83 | modifiers 84 | 0 85 | modifiersubtext 86 | 87 | sourceoutputuid 88 | fileexists 89 | vitoclose 90 | 91 | 92 | 93 | 3AC6E7C7-6413-417D-AD47-9BE771BFE204 94 | 95 | 96 | destinationuid 97 | 1F866CED-855C-4BA8-A031-00095FD859ED 98 | modifiers 99 | 0 100 | modifiersubtext 101 | 102 | vitoclose 103 | 104 | 105 | 106 | 4C6BFBB2-35EB-4F7F-8A0D-DF1D23AA5F82 107 | 108 | 109 | destinationuid 110 | 1F866CED-855C-4BA8-A031-00095FD859ED 111 | modifiers 112 | 0 113 | modifiersubtext 114 | 115 | vitoclose 116 | 117 | 118 | 119 | 506077C9-6BF8-401D-B34D-ACAEAA975F30 120 | 121 | 122 | destinationuid 123 | 6F4C4F63-0697-43E5-B54D-317C51DFE8D5 124 | modifiers 125 | 0 126 | modifiersubtext 127 | 128 | vitoclose 129 | 130 | 131 | 132 | 5282A865-7975-4E03-83E0-721FD575334A 133 | 134 | 135 | destinationuid 136 | 6F4C4F63-0697-43E5-B54D-317C51DFE8D5 137 | modifiers 138 | 0 139 | modifiersubtext 140 | 141 | vitoclose 142 | 143 | 144 | 145 | 56EDA9A1-C8C3-4549-B3C3-6AA5DE031A82 146 | 147 | 148 | destinationuid 149 | 228E56B9-B502-47BC-A003-D5EEF34EAE57 150 | modifiers 151 | 0 152 | modifiersubtext 153 | 154 | vitoclose 155 | 156 | 157 | 158 | 69FFC4C2-6426-4DAA-9482-2F8D817187E9 159 | 160 | 161 | destinationuid 162 | 4C6BFBB2-35EB-4F7F-8A0D-DF1D23AA5F82 163 | modifiers 164 | 0 165 | modifiersubtext 166 | 167 | vitoclose 168 | 169 | 170 | 171 | 6F4C4F63-0697-43E5-B54D-317C51DFE8D5 172 | 173 | 703E2968-A7F7-4E94-A8D8-0D8959272616 174 | 175 | 176 | destinationuid 177 | 08710451-91D1-4889-A4BC-D21F87618050 178 | modifiers 179 | 0 180 | modifiersubtext 181 | 182 | vitoclose 183 | 184 | 185 | 186 | 9981F708-6C83-44CD-BC06-B6C10A2B00F6 187 | 188 | 189 | destinationuid 190 | 1F866CED-855C-4BA8-A031-00095FD859ED 191 | modifiers 192 | 0 193 | modifiersubtext 194 | 195 | vitoclose 196 | 197 | 198 | 199 | 9EF074A1-94E0-4F66-86BF-D7B5ECC89006 200 | 201 | 202 | destinationuid 203 | 56EDA9A1-C8C3-4549-B3C3-6AA5DE031A82 204 | modifiers 205 | 0 206 | modifiersubtext 207 | 208 | vitoclose 209 | 210 | 211 | 212 | DA2EB775-5859-482A-8BF0-499164DCDA0B 213 | 214 | 215 | destinationuid 216 | 0D6DB001-6C1A-4973-BD3C-0CD4706096CB 217 | modifiers 218 | 0 219 | modifiersubtext 220 | 221 | vitoclose 222 | 223 | 224 | 225 | E10BD1B9-6B0C-46B3-A10F-160E8479AF46 226 | 227 | 228 | destinationuid 229 | 215704C1-57CC-42F0-B541-8DF173C3A7D0 230 | modifiers 231 | 0 232 | modifiersubtext 233 | 234 | vitoclose 235 | 236 | 237 | 238 | F5791817-ED09-4C74-8E36-08CDB556B5CB 239 | 240 | 241 | destinationuid 242 | 69FFC4C2-6426-4DAA-9482-2F8D817187E9 243 | modifiers 244 | 0 245 | modifiersubtext 246 | 247 | vitoclose 248 | 249 | 250 | 251 | F6A5F2E2-53D1-4E27-BBE2-B5BD60CF6EC7 252 | 253 | 254 | destinationuid 255 | C6719B8C-4E2D-4EBF-9FEC-B0FE295B67AC 256 | modifiers 257 | 0 258 | modifiersubtext 259 | 260 | vitoclose 261 | 262 | 263 | 264 | 265 | createdby 266 | Dean Jackson 267 | description 268 | Find and open Sublime Text projects 269 | disabled 270 | 271 | name 272 | Sublime Text Projects 273 | objects 274 | 275 | 276 | config 277 | 278 | acceptsfiles 279 | 280 | acceptsmulti 281 | 1 282 | acceptstext 283 | 284 | acceptsurls 285 | 286 | name 287 | Open in Sublime Text 288 | 289 | type 290 | alfred.workflow.trigger.universalaction 291 | uid 292 | F6A5F2E2-53D1-4E27-BBE2-B5BD60CF6EC7 293 | version 294 | 1 295 | 296 | 297 | config 298 | 299 | concurrently 300 | 301 | escaping 302 | 102 303 | script 304 | ./alfred-sublime - <<< $@ 305 | scriptargtype 306 | 1 307 | scriptfile 308 | 309 | type 310 | 5 311 | 312 | inboundconfig 313 | 314 | externalid 315 | new 316 | 317 | type 318 | alfred.workflow.action.script 319 | uid 320 | 7143EA82-FD83-4544-93AC-3942DD1D6EC0 321 | version 322 | 2 323 | 324 | 325 | config 326 | 327 | concurrently 328 | 329 | escaping 330 | 102 331 | script 332 | if [ $# -eq 1 ]; then 333 | # fetch contents of single URL 334 | echo "downloading $1 ..." >&2 335 | curl -fsSL "$1" | ./alfred-sublime - 336 | else 337 | # treat multiple URLs as text 338 | ./alfred-sublime - <<< $@ 339 | fi 340 | scriptargtype 341 | 1 342 | scriptfile 343 | 344 | type 345 | 5 346 | 347 | inboundconfig 348 | 349 | externalid 350 | open-url 351 | 352 | type 353 | alfred.workflow.action.script 354 | uid 355 | C6719B8C-4E2D-4EBF-9FEC-B0FE295B67AC 356 | version 357 | 2 358 | 359 | 360 | config 361 | 362 | alfredfiltersresults 363 | 364 | alfredfiltersresultsmatchmode 365 | 0 366 | argumenttreatemptyqueryasnil 367 | 368 | argumenttrimmode 369 | 0 370 | argumenttype 371 | 1 372 | escaping 373 | 68 374 | keyword 375 | .st 376 | queuedelaycustom 377 | 1 378 | queuedelayimmediatelyinitially 379 | 380 | queuedelaymode 381 | 0 382 | queuemode 383 | 1 384 | runningsubtext 385 | Finding projects… 386 | script 387 | ./alfred-sublime -search -- "$1" 388 | scriptargtype 389 | 1 390 | scriptfile 391 | 392 | subtext 393 | Search and Open Sublime Text Projects 394 | title 395 | Sublime Text Projects 396 | type 397 | 0 398 | withspace 399 | 400 | 401 | inboundconfig 402 | 403 | externalid 404 | search 405 | inputmode 406 | 1 407 | 408 | type 409 | alfred.workflow.input.scriptfilter 410 | uid 411 | 0D6DB001-6C1A-4973-BD3C-0CD4706096CB 412 | version 413 | 3 414 | 415 | 416 | config 417 | 418 | action 419 | 0 420 | argument 421 | 0 422 | focusedappvariable 423 | 424 | focusedappvariablename 425 | 426 | hotkey 427 | 0 428 | hotmod 429 | 0 430 | leftcursor 431 | 432 | modsmode 433 | 0 434 | relatedAppsMode 435 | 0 436 | 437 | type 438 | alfred.workflow.trigger.hotkey 439 | uid 440 | DA2EB775-5859-482A-8BF0-499164DCDA0B 441 | version 442 | 2 443 | 444 | 445 | config 446 | 447 | acceptsfiles 448 | 449 | acceptsmulti 450 | 1 451 | acceptstext 452 | 453 | acceptsurls 454 | 455 | name 456 | Open in Sublime Text 457 | 458 | type 459 | alfred.workflow.trigger.universalaction 460 | uid 461 | 0BA23803-0255-4C05-9CD7-05738C9DBE24 462 | version 463 | 1 464 | 465 | 466 | config 467 | 468 | acceptsfiles 469 | 470 | acceptsmulti 471 | 1 472 | acceptstext 473 | 474 | acceptsurls 475 | 476 | name 477 | Open in Sublime Text 478 | 479 | type 480 | alfred.workflow.trigger.universalaction 481 | uid 482 | 3AC6E7C7-6413-417D-AD47-9BE771BFE204 483 | version 484 | 1 485 | 486 | 487 | config 488 | 489 | lastpathcomponent 490 | 491 | onlyshowifquerypopulated 492 | 493 | removeextension 494 | 495 | text 496 | {query} 497 | title 498 | Sublime Text 499 | 500 | type 501 | alfred.workflow.output.notification 502 | uid 503 | 6F4C4F63-0697-43E5-B54D-317C51DFE8D5 504 | version 505 | 1 506 | 507 | 508 | config 509 | 510 | alfredfiltersresults 511 | 512 | alfredfiltersresultsmatchmode 513 | 0 514 | argumenttreatemptyqueryasnil 515 | 516 | argumenttrimmode 517 | 0 518 | argumenttype 519 | 1 520 | escaping 521 | 127 522 | queuedelaycustom 523 | 1 524 | queuedelayimmediatelyinitially 525 | 526 | queuedelaymode 527 | 0 528 | queuemode 529 | 1 530 | runningsubtext 531 | Reading settings… 532 | script 533 | ./alfred-sublime -conf -- "$1" 534 | scriptargtype 535 | 1 536 | scriptfile 537 | 538 | subtext 539 | View and edit workflow settings 540 | title 541 | Settings for Sublime Text Projects 542 | type 543 | 0 544 | withspace 545 | 546 | 547 | inboundconfig 548 | 549 | externalid 550 | config 551 | 552 | type 553 | alfred.workflow.input.scriptfilter 554 | uid 555 | 9981F708-6C83-44CD-BC06-B6C10A2B00F6 556 | version 557 | 3 558 | 559 | 560 | config 561 | 562 | argument 563 | {var:notification} 564 | passthroughargument 565 | 566 | variables 567 | 568 | 569 | type 570 | alfred.workflow.utility.argument 571 | uid 572 | 5282A865-7975-4E03-83E0-721FD575334A 573 | version 574 | 1 575 | 576 | 577 | config 578 | 579 | argument 580 | . 581 | /------------- ACTION IN -------------\ 582 | query={query} 583 | variables={allvars} 584 | \------------- ACTION IN -------------/ 585 | cleardebuggertext 586 | 587 | processoutputs 588 | 589 | 590 | type 591 | alfred.workflow.utility.debug 592 | uid 593 | 1F866CED-855C-4BA8-A031-00095FD859ED 594 | version 595 | 1 596 | 597 | 598 | type 599 | alfred.workflow.utility.hidealfred 600 | uid 601 | 08710451-91D1-4889-A4BC-D21F87618050 602 | version 603 | 1 604 | 605 | 606 | config 607 | 608 | inputstring 609 | {var:hide_alfred} 610 | matchcasesensitive 611 | 612 | matchmode 613 | 2 614 | matchstring 615 | (1|true) 616 | 617 | type 618 | alfred.workflow.utility.filter 619 | uid 620 | 703E2968-A7F7-4E94-A8D8-0D8959272616 621 | version 622 | 1 623 | 624 | 625 | config 626 | 627 | concurrently 628 | 629 | escaping 630 | 102 631 | script 632 | ./alfred-sublime $@ 633 | scriptargtype 634 | 1 635 | scriptfile 636 | 637 | type 638 | 5 639 | 640 | inboundconfig 641 | 642 | externalid 643 | open 644 | 645 | type 646 | alfred.workflow.action.script 647 | uid 648 | 506077C9-6BF8-401D-B34D-ACAEAA975F30 649 | version 650 | 2 651 | 652 | 653 | config 654 | 655 | action 656 | 0 657 | argument 658 | 0 659 | focusedappvariable 660 | 661 | focusedappvariablename 662 | 663 | hotkey 664 | 125 665 | hotmod 666 | 11796480 667 | hotstring 668 | 669 | leftcursor 670 | 671 | modsmode 672 | 0 673 | relatedApps 674 | 675 | com.apple.finder 676 | com.cocoatech.PathFinder 677 | 678 | relatedAppsMode 679 | 1 680 | 681 | type 682 | alfred.workflow.trigger.hotkey 683 | uid 684 | F5791817-ED09-4C74-8E36-08CDB556B5CB 685 | version 686 | 2 687 | 688 | 689 | config 690 | 691 | concurrently 692 | 693 | escaping 694 | 0 695 | script 696 | 697 | scriptargtype 698 | 1 699 | scriptfile 700 | FinderSelection.js 701 | type 702 | 8 703 | 704 | type 705 | alfred.workflow.action.script 706 | uid 707 | 69FFC4C2-6426-4DAA-9482-2F8D817187E9 708 | version 709 | 2 710 | 711 | 712 | type 713 | alfred.workflow.utility.junction 714 | uid 715 | 4C6BFBB2-35EB-4F7F-8A0D-DF1D23AA5F82 716 | version 717 | 1 718 | 719 | 720 | config 721 | 722 | externaltriggerid 723 | {var:trigger} 724 | passinputasargument 725 | 726 | passvariables 727 | 728 | workflowbundleid 729 | self 730 | 731 | type 732 | alfred.workflow.output.callexternaltrigger 733 | uid 734 | 228E56B9-B502-47BC-A003-D5EEF34EAE57 735 | version 736 | 1 737 | 738 | 739 | config 740 | 741 | json 742 | { 743 | "alfredworkflow" : { 744 | "arg" : "{var:query}", 745 | "config" : { 746 | "passinputasargument" : true, 747 | "externaltriggerid" : "{var:trigger}", 748 | "workflowbundleid" : "self", 749 | "passvariables" : false 750 | }, 751 | "variables" : { 752 | } 753 | } 754 | } 755 | 756 | type 757 | alfred.workflow.utility.json 758 | uid 759 | 56EDA9A1-C8C3-4549-B3C3-6AA5DE031A82 760 | version 761 | 1 762 | 763 | 764 | config 765 | 766 | inputstring 767 | {var:trigger} 768 | matchcasesensitive 769 | 770 | matchmode 771 | 1 772 | matchstring 773 | 774 | 775 | type 776 | alfred.workflow.utility.filter 777 | uid 778 | 9EF074A1-94E0-4F66-86BF-D7B5ECC89006 779 | version 780 | 1 781 | 782 | 783 | config 784 | 785 | action 786 | 0 787 | argument 788 | 1 789 | focusedappvariable 790 | 791 | focusedappvariablename 792 | 793 | hotkey 794 | 125 795 | hotmod 796 | 11796480 797 | hotstring 798 | 799 | leftcursor 800 | 801 | modsmode 802 | 0 803 | relatedApps 804 | 805 | com.sublimetext.4 806 | com.cocoatech.PathFinder 807 | com.runningwithcrayons.Alfred-Preferences 808 | com.apple.finder 809 | 810 | relatedAppsMode 811 | 2 812 | 813 | type 814 | alfred.workflow.trigger.hotkey 815 | uid 816 | E10BD1B9-6B0C-46B3-A10F-160E8479AF46 817 | version 818 | 2 819 | 820 | 821 | config 822 | 823 | fileutivariablename 824 | 825 | outputfileuti 826 | 827 | 828 | type 829 | alfred.workflow.utility.file 830 | uid 831 | 215704C1-57CC-42F0-B541-8DF173C3A7D0 832 | version 833 | 1 834 | 835 | 836 | readme 837 | Sublime Text Projects 838 | ===================== 839 | 840 | Open Sublime Text projects from Alfred. 841 | 842 | The workflow scans your system for `.sublime-project` files and allows you to open them in Sublime or the project directories in Finder. 843 | 844 | It primarily uses `mdfind` and `locate` (if enabled) to search your system. There is an optional `find`-based scanner to "fill the gaps". 845 | 846 | You can disable a specific scanner by setting its INTERVAL_* setting to 0. 847 | 848 | Set VSCODE to 1 (or true) to find VSCode projects instead of Sublime ones. 849 | 850 | By default, copying or calling Universal Actions on a search result uses the first project directory. Set ACTION_PROJECT_FILE to 1 (or true) to pass the path of the project file instead. 851 | 852 | To add additional directories to scan, use `.stconfig > Edit Config File`. 853 | uidata 854 | 855 | 08710451-91D1-4889-A4BC-D21F87618050 856 | 857 | xpos 858 | 1060 859 | ypos 860 | 320 861 | 862 | 0BA23803-0255-4C05-9CD7-05738C9DBE24 863 | 864 | note 865 | Open new document with passed-in text 866 | xpos 867 | 790 868 | ypos 869 | 45 870 | 871 | 0D6DB001-6C1A-4973-BD3C-0CD4706096CB 872 | 873 | note 874 | Filter Sublime Text projects 875 | xpos 876 | 240 877 | ypos 878 | 45 879 | 880 | 1F866CED-855C-4BA8-A031-00095FD859ED 881 | 882 | xpos 883 | 835 884 | ypos 885 | 260 886 | 887 | 215704C1-57CC-42F0-B541-8DF173C3A7D0 888 | 889 | xpos 890 | 280 891 | ypos 892 | 620 893 | 894 | 228E56B9-B502-47BC-A003-D5EEF34EAE57 895 | 896 | xpos 897 | 1220 898 | ypos 899 | 540 900 | 901 | 3AC6E7C7-6413-417D-AD47-9BE771BFE204 902 | 903 | note 904 | Open file(s) 905 | xpos 906 | 535 907 | ypos 908 | 45 909 | 910 | 4C6BFBB2-35EB-4F7F-8A0D-DF1D23AA5F82 911 | 912 | xpos 913 | 555 914 | ypos 915 | 450 916 | 917 | 506077C9-6BF8-401D-B34D-ACAEAA975F30 918 | 919 | note 920 | Perform action 921 | xpos 922 | 980 923 | ypos 924 | 405 925 | 926 | 5282A865-7975-4E03-83E0-721FD575334A 927 | 928 | xpos 929 | 980 930 | ypos 931 | 260 932 | 933 | 56EDA9A1-C8C3-4549-B3C3-6AA5DE031A82 934 | 935 | xpos 936 | 1065 937 | ypos 938 | 570 939 | 940 | 69FFC4C2-6426-4DAA-9482-2F8D817187E9 941 | 942 | xpos 943 | 240 944 | ypos 945 | 420 946 | 947 | 6F4C4F63-0697-43E5-B54D-317C51DFE8D5 948 | 949 | xpos 950 | 1220 951 | ypos 952 | 230 953 | 954 | 703E2968-A7F7-4E94-A8D8-0D8959272616 955 | 956 | xpos 957 | 980 958 | ypos 959 | 320 960 | 961 | 7143EA82-FD83-4544-93AC-3942DD1D6EC0 962 | 963 | xpos 964 | 985 965 | ypos 966 | 45 967 | 968 | 9981F708-6C83-44CD-BC06-B6C10A2B00F6 969 | 970 | note 971 | View workflow configuration 972 | xpos 973 | 240 974 | ypos 975 | 230 976 | 977 | 9EF074A1-94E0-4F66-86BF-D7B5ECC89006 978 | 979 | xpos 980 | 980 981 | ypos 982 | 570 983 | 984 | C6719B8C-4E2D-4EBF-9FEC-B0FE295B67AC 985 | 986 | note 987 | Create new file with contents of URL 988 | xpos 989 | 1365 990 | ypos 991 | 45 992 | 993 | DA2EB775-5859-482A-8BF0-499164DCDA0B 994 | 995 | xpos 996 | 45 997 | ypos 998 | 45 999 | 1000 | E10BD1B9-6B0C-46B3-A10F-160E8479AF46 1001 | 1002 | colorindex 1003 | 1 1004 | note 1005 | Files selected in any other app 1006 | xpos 1007 | 70 1008 | ypos 1009 | 600 1010 | 1011 | F5791817-ED09-4C74-8E36-08CDB556B5CB 1012 | 1013 | colorindex 1014 | 1 1015 | note 1016 | (Path) Finder selection or window target 1017 | xpos 1018 | 70 1019 | ypos 1020 | 420 1021 | 1022 | F6A5F2E2-53D1-4E27-BBE2-B5BD60CF6EC7 1023 | 1024 | note 1025 | Open new document with contents of URL 1026 | xpos 1027 | 1170 1028 | ypos 1029 | 45 1030 | 1031 | 1032 | variables 1033 | 1034 | ACTION_PROJECT_FILE 1035 | false 1036 | INTERVAL_FIND 1037 | 30m 1038 | INTERVAL_LOCATE 1039 | 12h 1040 | INTERVAL_MDFIND 1041 | 10m 1042 | VSCODE 1043 | false 1044 | 1045 | variablesdontexport 1046 | 1047 | ACTION_PROJECT_FILE 1048 | VSCODE 1049 | 1050 | version 1051 | 3.3.0-beta2 1052 | webaddress 1053 | https://github.com/deanishe/alfred-sublime-text 1054 | 1055 | 1056 | --------------------------------------------------------------------------------