Last active
January 2, 2024 07:19
-
-
Save OdatNurd/1ffe7cae1edfcefda368b69ad9af03f8 to your computer and use it in GitHub Desktop.
Sublime Text plugin for making opened files open in the window for the project
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import sublime | |
import sublime_plugin | |
from os.path import isabs, isfile, normpath, realpath, dirname, join | |
# Related reading: | |
# https://forum.sublimetext.com/t/forbid-st-from-opening-non-project-files-in-the-projects-window/68989 | |
# The name of the window specific setting that determines if the functionality | |
# of this plugin is enabled or not, and an indication of whether the plugin | |
# functionality is enabled by default or not. | |
IS_ENABLED = '_project_specific_files' | |
ENABLED_DEFAULT = True | |
def get_target_window(file_name): | |
""" | |
Iterates through all of the windows that currently exist and create a dict | |
that associates each unique open folder with the window or windows that | |
carry that path. | |
Windows for which there is no folders open are associated with the empty | |
path name ''. | |
Returns back a list of the windows that this file should exist in based on | |
the path name of the file; this will return None if there are no windows | |
that are appropriate, such as if the file doesn't associate with a project | |
and there are no non-project windows open. | |
""" | |
result = {} | |
def get_list(folder): | |
""" | |
Look up the list of windows in the result set that associates with the | |
provided path; if there is not one, add a new empty list for that path. | |
""" | |
items = result.get(folder, []) | |
result[folder] = items | |
return items | |
# Iterate over all windows, filling up the result list with all of the | |
# unique folders that are open across all windows, and associate each path | |
# with the window or windows that have that folder open in the side bar. | |
for window in sublime.windows(): | |
# Get the project data and project filename for the window. Windows | |
# with no folders will have empty project data, and windows with no | |
# project file will have an empty file name. | |
project_data = window.project_data() or {} | |
project_path = dirname(window.project_file_name() or '') | |
# Get the list of folders out of the window; if there are no folders, | |
# then associate this window with the empty path. | |
folders = project_data.get('folders', []) | |
if not folders: | |
get_list('').append(window) | |
# For each folder that's open, get the full absolute path. If the path | |
# is relative, it will be relaive to the project file, so adjust as | |
# needed. Each folder will associate with this window. | |
for folder in folders: | |
path = folder.get('path', '') | |
if not isabs(path): | |
path = normpath(join(project_path, path)) | |
get_list(path).append(window) | |
# When opening files from the command line via subl (and maybe at | |
# other times too) the file path that Sublime delivers in on_load | |
# has symlinks resolved; so, add that path here. | |
resolved = realpath(path) | |
if path != resolved: | |
get_list(resolved).append(window) | |
# Get the list of folders that we found, and sort it based on length, with | |
# the longest paths first. This ensures that if any sub folders of a path | |
# are present along with the parent path, that we can find the subpath | |
# first since that is more specific. | |
file_path = dirname(file_name) | |
for path in sorted(result.keys(), key=lambda p: len(p), reverse=True): | |
# If the filename starts with this path, use the first window we found | |
# that has this path. | |
if file_path.startswith(path): | |
return result[path][0] | |
# There are no windows currently open that have a path that matches the | |
# provided file, and there are also no windows open that just have no path, | |
# so return None to indicate that. | |
return None | |
class ToggleProjectSpecificFilesCommand(sublime_plugin.WindowCommand): | |
""" | |
Toggle the enabled status of the plugin in the current window between on | |
and off; when off, the event listener below does nothing. | |
""" | |
def run(self): | |
enabled = not self.window.settings().get(IS_ENABLED, ENABLED_DEFAULT) | |
self.window.settings().set(IS_ENABLED, enabled) | |
status = 'enabled' if enabled else 'disabled' | |
self.window.status_message(f'Project specific file loads are {status}') | |
class ProjectFileEventListener(sublime_plugin.EventListener): | |
""" | |
Listen to events that allow us to detect when a file that has been opened | |
does not belong in the current window, and move it to the window in which | |
it does belong, if any. | |
""" | |
skip_next_load = False | |
def on_window_command(self, window, command, args): | |
""" | |
Listen for window commands that are trying to open explicit files; if | |
those are seen, set the flag that will tell the on_load listener that | |
it should not try to move the file because the open was intentional. | |
""" | |
if command in ('reopen_last_file', 'open_file', 'prompt_open_file'): | |
# prompt_open_file can be cancelled, which will leave the flag set | |
# and could cause an externally opened file to not be moved; the | |
# only good way around that is to have some timeout on setting the | |
# flag that forces it to be unset or similar. This doesn't do that | |
# currently because this is a rare situation. | |
# | |
# Note also that if a command (e.g. edit_settings) invokes one of | |
# the above commands more than once, the event listener might only | |
# see the first one; this may also be an issue but there's not a | |
# lot to be done about it. | |
self.skip_next_load = True | |
def on_load(self, view): | |
""" | |
Listen for a file being opened; we check the path of the file to see | |
which window it should be associated with, and shift it to the correct | |
window if not. | |
""" | |
# Determine if the plugin functionality is enabled in the window the | |
# file was opened in, and wether or not this file is flagged with the | |
# temporary setting that says that this view was loaded as a result of | |
# a previous tab move. We also check to see if this file is a package | |
# file; all such files should open in whatever window is current with | |
# no handling, since those are explicit loads always. | |
enabled = view.window().settings().get(IS_ENABLED, ENABLED_DEFAULT) | |
is_pkg_file = view.file_name().startswith(sublime.packages_path()) | |
is_moved = view.settings().get('_moved_file', False) | |
print(not enabled, is_pkg_file, is_moved, self.skip_next_load) | |
# If the plugin isn't enabled, the file has already been moved, or we | |
# have the flag set saying that we should skip the next load, then | |
# reset the flag, erase the setting, and do nothing. | |
if not enabled or is_pkg_file or is_moved or self.skip_next_load: | |
self.skip_next_load = False | |
view.settings().erase('_moved_file') | |
return | |
# Determine what window this file should be contained in based on the | |
# path that it has. | |
target_window = get_target_window(view.file_name()) | |
# If the target window ends up None, then the path of this file does | |
# not associate with any existing window and there are no windows that | |
# don't have a folder open, so we need to make a new one. | |
if target_window is None: | |
sublime.run_command('new_window') | |
target_window = sublime.active_window() | |
# If the window the file is in and the target window are not the same, | |
# then we have to move the file to the appropriate window, which we do | |
# by opening the file in the new window and closing the version in this | |
# window. When we move the file, we flag it with a setting to let the | |
# next call to on_load() know that it doesn't need to do anything. | |
if view.window() != target_window: | |
new_view = target_window.open_file(view.file_name()) | |
new_view.settings().set('_moved_file', True) | |
# If the file that we're moving doesn't exist on disk, then someone | |
# just tried to open a nonexistant file to create it; in that case | |
# mark the buffer as scratch before we close it. | |
if not isfile(view.file_name()): | |
view.set_scratch(True) | |
view.close() | |
# Bring the target window to the front. | |
target_window.bring_to_front() |
Just as an FYI for the above, the plugin does indeed need ST4 and Python 3.8
in order to run; the User
package is always executed in the 3.8 plugin environment, but all other packages default to the legacy 3.3
environment.
The general use case is to put your own augment plugins into User
, in which case it will Just Work ™️ , but if you put it in some other package you need a .python-version
file as outlined above.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
For anyone considering using this plugin:
If you have downloaded or cloned this gist into your
Packages
folder and encounter the following console error:This is because the default Python version for the
Packages
folder is 3.3, while f-strings such asf"Project specific file loads are {status}"
require at least 3.6.To resolve this, simply create a
.python-version
file with the content3.8
(refer to the ST api_environments docs) in the same folder where you placedproject_specific_files.py
.--
However, in my case, it worked perfectly even without the
.python-version
file when the plugin was in the...\Sublime Text\Packages\User
folder, but then I moved the file to a separate folder...\Sublime Text\Packages\ProjectSpecificFiles\
and got this error.