Sublime Forum

How to auto-save unsaved tabs in Sublime Text?

#1

I am using Sublime Text on Mac OS X. During the day I keep a sublime window open and keep adding notes by opening up new tabs in it. At the end of the week I have around 50 tabs open. I don’t want to lose these notes, so I save them in a directory with random names (from the close dialog). It is a bit of a pain to close each unsaved tab and provide a random filename for it for 50+ files.

Is there a way I can automate this whole flow?

0 Likes

#2

A plugin that scans a window for all files that don’t have any name associated with them and forces them to save to disk with a random name would be very easy to create, so long as you’re serious about the name being random (e.g. a globally unique value that’s just a string of random gibberish).

As soon as you want to save them with a name that might allow you to determine which file is which, things get real complicated real quick since in that case it needs to first come up with some sort of name and secondly also take care to not clobber anything that already exists, etc.

What sort of names are you looking for here?

0 Likes

#4

I am fine with any name. Since, the content of the file is what matters to me, and I can search in all files to find them.

Timestamp would be a good option if the filename is given when a new tab is created. However, since I am saving them all at once, timestamp might collide. Is there a way to fetch the time of creation of a tab? I can probably use that to name the file, and to make sure if does collide with any other file, I can add some random characters to it. For example 2022-01-22-07-24-12-812472847216.txt

Also, are you aware of any resources/references that can help me develop this plugin? I have not development any Sublime plugins in the past.

0 Likes

#5

A simple example of something like this that you can build on would be the following plugin; this assumes that you’re using Sublime Text 4 and that the plugin will be saved into your User package. If that’s not the case, you need to replace the f-string with a call to format() as appropriate.

import sublime
import sublime_plugin

from datetime import datetime
from uuid import uuid4

import os


# The location that all temporary files will be saved to; this must point
to a # folder that exists on disk; any time the command is triggered in a
window, # all temporary buffers in that window will be saved here.
tmp_file_dir = "/tmp/bob/"

# When temporary files are saved to disk into the file folder above, this
# is type of file that they are saved as.
tmp_file_type = ".txt"


class SaveTemporaryBuffersCommand(sublime_plugin.WindowCommand):
    """
    When executed, this command looks for all files in the current window
    that have an assigned temporary name and forces them to be saved to
    disk into the temporary folder using that name.

    This doesn't check that filenames are going to collide with existing
    files and will blindly clobber any file in the temporary folder that
    happens to already exist.
    """
    def run(self):
        # For every file inside of the current window, if it has the
        # setting that has the temporary name (i.e. it is a newly created
        # tab that has not already been saved at least once), save it.
        for view in self.window.views():
            tmp_name = view.settings().get("_tmp_name")
            if tmp_name is not None:
                # Construct a final filename, force this view to assume
                # that is the filename that it should have, and then tell
                # it to save.
                #
                # This is the same as you manually saving the file, giving
                # it the temporary name and choosing the temporary folder,
                # and then saying "Yes" if you are asked "Would you like
                # to overwrite this file?"
                filename = os.path.join(tmp_file_dir, tmp_name,)
                view.retarget(filename)
                view.run_command("save")


class TemporaryFileEventListener(sublime_plugin.EventListener):
    """
    This event listener will watch for new tabs to be created and flag
    them with an internal setting that indicates what the date and time
    was that the tab was created, plus a random suffix to ensure no
    collisions.

    Any time a file is saved, the setting is removed from the buffer (no
    matter the name it was saved with). This means that at any point we
    can easily gather
    """
    # Whenever a new tab is created, capture a potential temporary name
    # based on the current date and time and a unique suffix, and store it
    # into a setting in the view to be retreived later.
    def on_new(self, view):
        # Using the current date and time and a unique ID, come up with a
        # new temporary name for this file
        now = datetime.now().strftime("%Y-%m-%d_%H%M")
        rnd = str(uuid4())[:8]
        tmp_name = f"{now}-{rnd}{tmp_file_type}"

        # Store the name into a setting that we can distinguish later
        view.settings().set("_tmp_name", tmp_name)

        # Default this file to wanting to save itself into the temporary
        # folder and to use the temporary name provided. This will give
        # the tab a visible name that represents the name it will be saved
        # to, and will offer to put it into that folder if you try to save
        # it manually.
        view.settings().set("default_dir", tmp_file_dir)
        view.set_name(tmp_name)

    # When this buffer is saved, remove the setting that indicates what
    # the temporary name we gave it was; it may or may not be saved under
    # this name but once the file is on disk, it has an official name and
    # thus this is no longer required.
    def on_save(self, view):
        view.settings().erase("_tmp_name")

In this example, once the plugin is in place every time you create a new tab it will visibly have a temporary name; trying to manually save will offer that name by default and will also offer to store the file into the defined temporary file folder, which must exist.

This is done with an EventListener that watches for the on_new event to know that a new tab is being created which sets things up. The temporary name is saved into a hidden setting along with setting up the tab name and default folder, and the setting is removed if the file happens to be saved, since at that point the plugin is no longer interesting.

There is also a command defined named save_temporary_buffers which, when invoked, will look for all tabs into the window with the temporary setting and force them to save to disk into the temporary folder, using the temporary name. If there are any filename collisions, the file is blindly clobbered with the new content.

The whole thing could be enhanced a few ways, but this will get you started. For example, maybe there should be a command that turns this on and off in a given window instead of having it always happen. Or maybe the tab shouldn’t be given the name and temporary directory and that can work as it normally does. Or it could be smart enough to use os.path.exists() on the filename and not save the file if it would clobber, leaving you to work out the appropriate name yourself in such a case (in which case, perhaps the random value on the end is not needed if you don’t create multiple tabs in a minute, or the temporary name could have seconds in the filename too which would make it less likely), etc.

There is API Documentation that discusses the Python API for plugins; there is also a Tutorial listed in the documentation, although it’s for Sublime Text 2, so some portions of it no longer apply.

Apart from that, the only other resources that I’m aware of personally are the following playlists of videos (which, it must be said, I may possibly be a little biased about). The first one contains a 5 video series that goes over creating a particular package from scratch and the second is a more comprehensive course.

I regularly stream on both Twitch and YouTube so if you’re familiar with either of those platforms you’re more than welcome to join a stream to ask questions about this or anything Sublime related as well.

1 Like

#6

Good answer. Learnt something new today. :grinning:

0 Likes

#7

Thanks a lot Terence for the answer. Your code just worked perfectly for my use case.

Here are a couple of changes I would prefer to have just to iron out things:

  • Add seconds to the filename (Yes, it is very like for me to create more than one file in a minute). Although I won’t remove the random bit. But adding seconds will help in sorting the filenames as they were created. The random bit doesn’t give a surety of the lexicographic order when one file is created after the other.
  • Some toggle to enable/disable this behavior (I only use one directory for all my notes, let’s say /my/personal/notes, Is there a way we can add all the locations I want to enable this behavior to be enabled in to a configuration file, something that maintains a list and then compare that to match any window having that folder open to enable this using window.project_data()['folders']
  • Adding os.path.exists() looks something which is a good-to-have as I don’t anticipate any collisions for my use cases.
0 Likes

#8

To do that, you can add %s to the datetime call that’s used to come up with the temporary filename.

datetime.now().strftime("%Y-%m-%d_%H%M%s")

For this one, you can add in the call after the point where it creates the temporary filename but before it does the retarget() and run_command() that creates the file:

                filename = os.path.join(tmp_file_dir, tmp_name,)
                if not os.path.exists(filename):
                    view.retarget(filename)
                    view.run_command("save")

With that in place, it will only save the buffer if the file doesn’t exist. If it does, that view will be skipped, so it would fall back to what you otherwise do.

That’s also definitely possible; I presume in such a case instead of using the hard coded folder it would dump the files into the one open in the window? For something like that the general pattern is to use the first folder that’s open if there’s more than one. Would that be acceptable?

0 Likes

#9

Here’s a completely new version of the plugin from above, modified so that:

  • It will use seconds in the temporary file name
  • It won’t clobber over files that might exist
  • The folders it can save into and the extension to use are configurable (including per project)
  • It will only activate itself if used in a window where a configured folder is open.

More details are available in the comments at the top of the file.

import sublime
import sublime_plugin

from datetime import datetime
from uuid import uuid4

from tempfile import gettempdir
import os

# Related Reading:
#     https://forum.sublimetext.com/t/how-to-auto-save-unsaved-tabs-in-sublime-text/62376


# This plugin creates a simple ability for you to create temporary tabs in a
# window and easily save them all to a configured folder with a single key
# binding, menu entry, command palette entry or key press.
#
# To use it, you first need to add the following settings to your
# Preferences.sublime-settings file. If not provided, the locations to save
# will default to the system temporary folder, and the file extension will
# default to .txt:
#
#       "auto_save_buffer.locations": [
#           "/my/personal/notes",
#       ],
#       "auto_save_buffer.default_extension": ".txt",
#
# These settings can be overridden in project specific settings if you would
# like to customize them per project/window.
#
# Next you need to set up a key binding, menu, etc that will invoke the
# appropriate command to trigger temporary file saves; an example would be:
#
#    { "keys": ["ctrl+alt+shift+s", "command": "save_temporary_buffers"] }
#
# Whenver a new tab is created, the list of folders open in the window (if any)
# is searched to see if it contains one of the configured temporary folders. If
# it does not, nothing happens.
#
# When it does, the new tab is given a temporary name based on the current date
# and time, and associated with the matching temporary folder; the tab name is
# set to the temporary name as a visual hint that this has happened.
#
# Any number of such tabs can be created; when the command is triggered in a
# window, all of the tabs that have been assigned temporary names will be saved
# to disk automatically using their temporary name. If such a file already
# exists, that file will cowardlyl refuse to save since it will clobber over
# the file.
#
# In a case where the list of folders open in the window contains multiple
# configured folders, the one closest to the top of the folder list in the
# window will be used.


def get_temp_dir(view):
    """
    Given a view, figure out which (if any) of the configured temporary buffer
    save locations we should save this view into. This will use the list of
    folders that's currently open in the window, favoring them such that if
    there are multiple matches, the one closest to the top of the folder list
    in the side bar wins.
    """
    # Fetch the list of potential autosave locations
    dirs = view.settings().get("auto_save_buffer.locations", [gettempdir()])

    # Get the list of folders available in the window, which could be none
    window = view.window() or sublime.active_window()
    folders = window.folders() or []

    print(f"Configured folders are: {dirs}")
    for folder in folders:
        print(f"Considering {folder}")
        if folder in dirs:
            return folder

    return None


class SaveTemporaryBuffersCommand(sublime_plugin.WindowCommand):
    """
    When executed, this command looks for all files in the current window
    that have an assigned temporary name and forces them to be saved to
    disk into the temporary folder using that name.

    This doesn't check that filenames are going to collide with existing
    files and will blindly clobber any file in the temporary folder that
    happens to already exist.
    """
    def run(self):
        # For every file inside of the current window, if it has the
        # setting that has the temporary name (i.e. it is a newly created
        # tab that has not already been saved at least once), save it.
        for view in self.window.views():
            tmp_name = view.settings().get("_tmp_name")
            tmp_dir = view.settings().get("_tmp_dir", gettempdir())
            view.settings().erase("_tmp_name")
            view.settings().erase("_tmp_dir")
            if tmp_name is not None:
                # Construct a final filename, force this view to assume
                # that is the filename that it should have, and then tell
                # it to save.
                #
                # This is the same as you manually saving the file, giving
                # it the temporary name and choosing the temporary folder,
                # and then saying "Yes" if you are asked "Would you like
                # to overwrite this file?"
                filename = os.path.join(tmp_dir, tmp_name,)
                if not os.path.exists(filename) and view.name() == tmp_name:
                    view.retarget(filename)
                    view.run_command("save")


class TemporaryFileEventListener(sublime_plugin.EventListener):
    """
    This event listener will watch for new tabs to be created and flag
    them with an internal setting that indicates what the date and time
    was that the tab was created, plus a random suffix to ensure no
    collisions.

    Any time a file is saved, the setting is removed from the buffer (no
    matter the name it was saved with). This means that at any point we
    can easily gather
    """
    # Whenever a new tab is created, capture a potential temporary name
    # based on the current date and time and a unique suffix, and store it
    # into a setting in the view to be retreived later.
    def on_new(self, view):
        # Get the extension and temporary directory that this file should use.
        # If there's no temporary directory returned, then do nothing.
        ext = view.settings().get("auto_save_buffer.default_extension", ".txt")
        tmp_dir = get_temp_dir(view)
        if tmp_dir is None:
            return

        # Using the current date and time and a unique ID, come up with a
        # new temporary name for this file
        now = datetime.now().strftime("%Y-%m-%d_%H%M%S")
        rnd = str(uuid4())[:8]
        tmp_name = f"{now}-{rnd}{ext}"

        # Store the temporary path and file name into a setting that we can
        # distinguish later
        view.settings().set("_tmp_dir", tmp_dir)
        view.settings().set("_tmp_name", tmp_name)

        # Default this file to wanting to save itself into the temporary
        # folder and to use the temporary name provided. This will give
        # the tab a visible name that represents the name it will be saved
        # to, and will offer to put it into that folder if you try to save
        # it manually.
        view.settings().set("default_dir", tmp_dir)
        view.set_name(tmp_name)

    # When this buffer is saved, remove the settings that indicates what the
    # temporary name we gave it was and what folder it was to be stored in; it
    # may or may not be saved under this name but once the file is on disk, it
    # has an official name and thus this is no longer required.
    def on_save(self, view):
        view.settings().erase("_tmp_dir")
        view.settings().erase("_tmp_name")

This was done in Live Stream #112 for anyone that would like to see the rationale/design for how and why it was made this way, including an description of how it works.

1 Like