Skip to content

Instantly share code, notes, and snippets.

@OdatNurd
Last active January 2, 2024 07:19
Show Gist options
  • Save OdatNurd/1ffe7cae1edfcefda368b69ad9af03f8 to your computer and use it in GitHub Desktop.
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
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()
@alexchexes
Copy link

For anyone considering using this plugin:

If you have downloaded or cloned this gist into your Packages folder and encounter the following console error:

reloading python 3.3 plugin project_specific_files.project_specific_files
Traceback (most recent call last):
  File "C:\Program Files\Sublime Text\Lib\python33\sublime_plugin.py", line 308, in reload_plugin
    m = importlib.import_module(modulename)
  File "./python3.3/importlib/__init__.py", line 90, in import_module
  File "<frozen importlib._bootstrap>", line 1584, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1565, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1532, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 584, in _check_name_wrapper
  File "<frozen importlib._bootstrap>", line 1022, in load_module
  File "<frozen importlib._bootstrap>", line 1003, in load_module
  File "<frozen importlib._bootstrap>", line 560, in module_for_loader_wrapper
  File "<frozen importlib._bootstrap>", line 853, in _load_module
  File "<frozen importlib._bootstrap>", line 980, in get_code
  File "<frozen importlib._bootstrap>", line 313, in _call_with_frames_removed
  File "C:\Users\Alex\AppData\Roaming\Sublime Text\Packages\ProjectSpecificFiles\project_specific_files.py", line 103
    self.window.status_message(f'Project specific file loads are {status}')

This is because the default Python version for the Packages folder is 3.3, while f-strings such as f"Project specific file loads are {status}" require at least 3.6.

To resolve this, simply create a .python-version file with the content 3.8 (refer to the ST api_environments docs) in the same folder where you placed project_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.

@OdatNurd
Copy link
Author

OdatNurd commented Jan 2, 2024

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