Sublime Forum

Forbid ST from opening non-project files in the project's window

#1

Is there a setting, a plugin, or something to make Sublime Text work this way:

If you have the project window open and try to open a file NOT from your project folder (using Windows Explorer), Sublime Text should open a new window outside the project (or use another window outside the project if there is one), instead of using the existing project’s window as it does now?

Currently, it’s very inconvenient: I have several projects opened in different windows, and when I open any other file that is not from one of these projects, it still opens it in the (last focused) project window.

It would be much better if, for non-project files, Sublime Text used a separate window.

Is there any way to achieve this? If there’s no direct solution, is it theoretically possible to create a plugin or some tweak for ST4 to make it work this way?

0 Likes

#2

Setting "open_files_in_new_window": "always" would cause any file to be opened in a new window. It doesn’t check, whether it belongs to a project though.

0 Likes

#4

Good notice, I missed this setting, thank you. Unfortunately, it is true that with this setting set to “always,” all files really always open in a new window, even if the file is from the project folder. I could live with that, but here’s another problem: if you open the 1st non-project file, then open the 2nd non-project file, you end up with 2 more new windows, beside the windows of your project that you already had. It would be great to have all non-project files opened in the same “non-project files window”.

The idea is: Since this kind of behavior control already exists within Sublime ("open_files_in_new_window" setting), we could probably write a user package to modify its behavior. How could I get some assistance in doing that? I mean, where do I start?

0 Likes

#5

In theory, an on_load event listener could compare the path of the file that was just opened to the list of folders (if any) that are open in the window, and if it’s not one of those close the file and then either create a new window and open the file there, or find the window that has that path and window.open_file() the path there.

There would probably be a a visible “blip” in the main window as the file is opened and immediately closed.

2 Likes

#6

Here is an example of a plugin that can do what is outlined above; it watches for a file to be loaded and checks the path of the file against the folders that are open in all available windows to determine which window the file should have be opened in.

This is done by associating a window with all of the top level folders it currently has open and checking against the path of the file to see which is the best match.

If the file is not currently in the window that it “should” be, it will be closed in the first window and re-opened in the new one.

In the case that the file path does not associate with any project that’s open, the file will be moved into the first window that has no folders open; if there is no such window, one will be created first.

Note as a caveat that on_load doesn’t know how the file was opened, only that it was; so this will also trigger when you use File > Open, the quick panel, etc. If this is an issue, more checks would have to be added.

We worked through this on stream; there is a highlight that goes through how it all works; the full development end to end is in the VOD, though that will only be around for a month or so.

import sublime
import sublime_plugin

from os.path import isabs, normpath, dirname, join


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)

    # 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 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.
    """
    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.
        """
        # If this view has the setting that says that we moved this file, then
        # assume that it's in the correct window, remove the temporary settting
        # and leave.
        if view.settings().get('_moved_file', 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)
            view.close()

3 Likes

#7

Omg, that’s incredible! Thank you a lot for the stream and the code!
With this 3-hour night stream, you saved dozens of hours of my time, as I would’ve needed to learn how to write ST plugins in order to do it by myself (along with learning Python in the first place :smile:). So, your deep involvement is greatly appreciated!! Thanks!!

Regarding the package itself, I just tried it and hell yeah, it works exactly as I wanted it to. The “blip” is barely perceptible, so, I guess I’ll start using this in my workflow.

I just need to solve one little problem. The thing is, there’s something wrong with the windows focus. If I have the project window focused and then I open a non-project file, your plugin moves the file to a new (or other) window as intended. But then, for some reason, this new/other window does not receive focus, or it does for a second and then the previous window (e.g. project window) gets focused again, so I don’t even see the newly opened window until I switch to it with Alt+Tab or so.

Is there a way to control window focus from the code? If so, I guess it’s just one more line I need to add, right?

on_load doesn’t know how the file was opened, only that it was. So, this will also trigger when you use File > Open , the quick panel, etc. If this is an issue, more checks would have to be added.

In most cases, this is not an issue at all, as I don’t often need files opened via File > Open to be open in the current Project window.

However, there’s one scenario where it does matter: Drag & Drop. When you Drag&Drop a file into a window, you expect it to be opened in that window, even if it’s a project window and the file is not (otherwise, you would simply open it instead of Drag&Drop). Along with the focusing issue I mentioned above, right now it feels slightly inconvenient. The same goes for the Ctrl+Shift+T case: a closed file that was closed in the project’s window, reopened in another window but doesn’t receive focus, so it looks like Ctrl+Shift+T just didn’t work at all (although it did).

Could you suggest how to properly implement the additional checks mentioned in your message (“more checks would have to be added”)?


P.S. For anyone else who is interested, the full OdatNurd’s stream link is: https://www.twitch.tv/videos/1893940021

P.P.S. OdatNurd, did you really make Sublime Text show the live chat and video? :face_with_monocle: I thought its maximum is to display images of few types, but not the live video actually :smiley: So cool

P.P.P.S. What ST color scheme do you use? I love those glowing keywords and braces as well as the font. How can I stole them=)? Is there way to adopt it for the Monokai?

0 Likes

#8

That one is an oversight on my part; that might be possible. Certainly you can focus a view, though I’m not sure offhand if you can swap the focus manually to another window.

I don’t think there’s any way to detect when a drag and drop is happening; I suspect the only way to pull that off would be to have the functionality be something that can be toggled on and off, so if you knew you were going to drag some files in you could temporarily disable it.

Regarding the re-open command; one of the things that’s possible is knowing when a command is about to execute, so possibly that could be leveraged to know that a file is about to be opened and should be exempted.

I’m going to be streaming again tonight, so we will research a bit and see what comes out; if so we’ll post up a second modified copy of things here.

Haha, no; I get that a lot, but that’s just a trick of live overlays. I have had it on my ToDo list to put twitch Chat directly in a Sublime window, since there are Python Twitch libraries out there; lots of projects on the go, though.

The color scheme is Mariana, but I have some customizations on it with additional rules; those could totally be rolled into Monokai if desired. This gist is a more or less up to date version of my customizations; the rule for glowing entities could be shifted over to another color scheme. The globals that mention glow are also contributing to that.

0 Likes

#9

The following is an updated version of the plugin outlined above; I’m posting this as a second post so that for the sake of posterity and comparison it’s possible to see what is different between the two.

The changes in this version over the above are:

  • You can enable or disable the plugin in specific windows; this is the only way to be able to drag and drop a file since the plugin can’t detect
  • Using File > Open or the command to reopen a previously closed file will now be exempt and will open the file directly without moving it
  • The plugin attempts to bring the new window to the front; depending on the platform in question this may or may not have an effect
  • Better handling for files that are stored in symlinked locations; in some cases the on_load will get the resolved filename and in some cases the link filename; the plugin will now work both ways.

To use the toggle, execute the toggle_project_specific_files command, which you can bind to a key or add to the command palette by creating a sublime-commands file with the following content:

[
  { "caption": "Toggle Project Specific File Loads",
    "command": "toggle_project_specific_files"
  }
]

The default state of the plugin is set by the constant at the top of the file, and when toggled the setting will be persisted, even across restarts of Sublime (so long as the window exists).

When the toggle happens, the status bar in the window will tell you what the current state now is.

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.
            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.
        enabled = view.window().settings().get(IS_ENABLED, ENABLED_DEFAULT)
        is_moved = view.settings().get('_moved_file', False)

        # 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_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()
1 Like

#10

Cool!! Now the thing is almost enterprise-ready; all the desired options are present. Too bad Sublime doesn’t provide any way to check how the file was opened. But yes, the “disable/enable plugin” hotkey (especially with a 1-key binding like “F5”) pretty much does the trick.

“Bring to front” works fine, although I’ve noticed one bug: if the window with the project doesn’t have any opened files, “bring_to_front” doesn’t work here’s the Dropbox video if needed.

Regretfully I missed your 2nd stream :frowning: It’s so early in my timezone, uhhh.


Offtopic

Thanks! I have tried it, and now I realize that the time when I will switch to a 4/5K display is getting nearer and nearer :smiley: 'Cause on my current 170PPI monitor, the “Fira Code” font looks terrible. Meanwhile, the default “Consolas” looks fine, but it’s not that impressive with the glow… need some tidy thin font.

0 Likes

#11

I was noticing last night that the front to front functionality does not work the way I would expect it to on Linux (or WSL Linux anyway), so this might be as good as it gets; I definitely noticed some weirdness.

I just realized you asked about the font I use and I didn’t mention. The standard response I have for that is:

I’m using a slightly modified version of the Iosevka font (StyleSet 9); The original can be found at https://github.com/be5invis/Iosevka - if you want my version, you can download it at http://bit.ly/odatnurd-iosevka

In a nutshell back when I started using this font I made a custom version that was a little wider because the default was too thin; that may or may not have changed in the base version. The bitly link will take you to a zip that has two versions of the font in it; they’re the same except the one with term in the name has no ligatures, so is better suited for use in a terminal.

1 Like

#12

Do you experience the same “weirdness” on Windows? I didn’t notice anything more than I described above…
And I didn’t quite get it about “front to front functionality”. What do you mean by it? Was it on the stream?

By the way, I modified the “on_load” function to check if the opened file is not a .sublime-[anything] file. All those files are supposed to be opened by ST in a split view with the initial .sublime- file on the left and a user’s overrides on the right. Without an additional check, the user’s overrides file gets moved to another window, and the initial settings file gets closed along with the window. Here’s the proposed fix:

    def on_load(self, view):
        //....
        if target_window is None:
            sublime.run_command('new_window')
            target_window = sublime.active_window()
        //....
        # 
        # Check that file extension doesn't start with ".sublime-"
        file_base_name, file_extension = os.path.splitext(view.file_name())
        if not file_extension.startswith(".sublime-"):
            if view.window() != target_window:
            //....


About the font, it’s incredible. For two hours yesterday I was combining my own custom Iosevka with all the things I need. Now I have the almost perfect font, eventually!
I hope they will add the ability to exclude specific ligatures (no need to confuse → with ->) and to adjust letter height a little, as the font now feels a bit stretched (maybe I am just too accustomed to the “square” letters after years of using Consolas).
Thank you for letting me know about this thing!!

0 Likes

#13

That’s a good catch; it would actually probably be better to skip on_load for anything whose path starts with sublime.packages_path(); there are more than just preferences files that might load; anything you open with View Package File is presumably something you intended to open, and that could be any sort of files.

0 Likes

#14

Ok, one last change related to your report, in the final version of this, which I’ve stored in the below gist; this shows all of the revisions (plus one extra where I forgot to remove some logging before I added the file).

This change makes anything in the sublime packages folder exempt; there is an edge case with opening preferences and the list because the plugin only catches the open_file command once and doesn’t ignore the other one.

There are a couple of fixes for this but the cleanest and easiest is just to recognize that opening a package file is always intentional and should not be interrupted in any way.

For example, all windows but one have folders open, then opening preferences will put the preferences in the single window but the defaults go away when their file gets closed (which also closes a window and futher distorts things).

So, we now just skip those kinds of files entirely; this is done by path and not extension since there are many sorts of resource files that may be opened that should be left as is (e.g. a readme).

2 Likes

#15

Thanks a lot! Now I’m using this, and still hoping that Sublime HQ will add this type of control as a native setting in the future! Good luck!

0 Likes