diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 5a98297d3f8847..a31ba3702d3bf8 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -534,6 +534,14 @@ http.cookies (Contributed by Nick Burns and Senthil Kumaran in :gh:`92936`.) +idlelib +------- + +* Add a "Reload from Disk" item to the File menu. This allows discarding + unsaved changes and reloading the current version of the file from the disk. + (Contributed by Shamil Abdulaev in :gh:`44968`.) + + inspect ------- diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index e0642cf0cabef0..9d7d1222938c4e 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -1,17 +1,20 @@ -"Test , coverage 17%." - -from idlelib import iomenu +import builtins +import os +import tempfile import unittest +from unittest.mock import patch + from test.support import requires from tkinter import Tk + +from idlelib import iomenu, util from idlelib.editor import EditorWindow -from idlelib import util from idlelib.idle_test.mock_idle import Func # Fail if either tokenize.open and t.detect_encoding does not exist. # These are used in loadfile and encode. # Also used in pyshell.MI.execfile and runscript.tabnanny. -from tokenize import open, detect_encoding +from tokenize import open as tokenize_open, detect_encoding # Remove when we have proper tests that use both. @@ -36,6 +39,14 @@ def tearDownClass(cls): cls.root.destroy() del cls.root + def _create_tempfile(self, content: str) -> str: + fd, filename = tempfile.mkstemp(suffix='.py') + os.close(fd) + self.addCleanup(os.unlink, filename) + with builtins.open(filename, 'w', encoding='utf-8') as f: + f.write(content) + return filename + def test_init(self): self.assertIs(self.io.editwin, self.editwin) @@ -45,17 +56,88 @@ def test_fixnewlines_end(self): fix = io.fixnewlines text = io.editwin.text - # Make the editor temporarily look like Shell. self.editwin.interp = None shelltext = '>>> if 1' self.editwin.get_prompt_text = Func(result=shelltext) - eq(fix(), shelltext) # Get... call and '\n' not added. + eq(fix(), shelltext) # Get... call and '\n' not added. del self.editwin.interp, self.editwin.get_prompt_text text.insert(1.0, 'a') - eq(fix(), 'a'+io.eol_convention) + eq(fix(), 'a' + io.eol_convention) eq(text.get('1.0', 'end-1c'), 'a\n') - eq(fix(), 'a'+io.eol_convention) + eq(fix(), 'a' + io.eol_convention) + + def test_reload_no_file(self): + io = self.io + io.filename = None + + with patch('idlelib.iomenu.messagebox.showinfo') as mock_showinfo: + result = io.reload(None) + self.assertEqual(result, "break") + mock_showinfo.assert_called_once() + args, kwargs = mock_showinfo.call_args + self.assertIn("File Not Found", args[0]) + + def test_reload_with_file(self): + io = self.io + text = io.editwin.text + original_content = "# Original content\n" + modified_content = "# Modified content\n" + + filename = self._create_tempfile(original_content) + io.filename = filename + + with patch('idlelib.iomenu.messagebox.showerror') as mock_showerror: + io.loadfile(io.filename) + self.assertEqual(text.get('1.0', 'end-1c'), original_content) + + with builtins.open(filename, 'w', encoding='utf-8') as f: + f.write(modified_content) + + result = io.reload(None) + + mock_showerror.assert_not_called() + self.assertEqual(result, "break") + self.assertEqual(text.get('1.0', 'end-1c'), modified_content) + + def test_reload_with_unsaved_changes_cancel(self): + io = self.io + text = io.editwin.text + original_content = "# Original content\n" + unsaved_content = original_content + "\n# Unsaved change" + + filename = self._create_tempfile(original_content) + io.filename = filename + io.loadfile(io.filename) + + text.insert('end', "\n# Unsaved change") + io.set_saved(False) + + with patch('idlelib.iomenu.messagebox.askokcancel', return_value=False) as mock_ask: + result = io.reload(None) + + self.assertEqual(result, "break") + self.assertEqual(text.get('1.0', 'end-1c'), unsaved_content) + mock_ask.assert_called_once() + + def test_reload_with_unsaved_changes_confirm(self): + io = self.io + text = io.editwin.text + original_content = "# Original content\n" + + filename = self._create_tempfile(original_content) + io.filename = filename + io.loadfile(io.filename) + + text.insert('end', "\n# Unsaved change") + io.set_saved(False) + + with patch('idlelib.iomenu.messagebox.askokcancel', return_value=True) as mock_ask: + result = io.reload(None) + + self.assertEqual(result, "break") + self.assertEqual(text.get('1.0', 'end-1c'), original_content) + mock_ask.assert_called_once() def _extension_in_filetypes(extension): diff --git a/Lib/idlelib/iomenu.py b/Lib/idlelib/iomenu.py index 464126e2df0668..7509bd36427992 100644 --- a/Lib/idlelib/iomenu.py +++ b/Lib/idlelib/iomenu.py @@ -31,6 +31,7 @@ def __init__(self, editwin): self.save_as) self.__id_savecopy = self.text.bind("<>", self.save_a_copy) + self.__id_reload = self.text.bind("<>", self.reload) self.fileencoding = 'utf-8' self.__id_print = self.text.bind("<>", self.print_window) @@ -40,6 +41,7 @@ def close(self): self.text.unbind("<>", self.__id_save) self.text.unbind("<>",self.__id_saveas) self.text.unbind("<>", self.__id_savecopy) + self.text.unbind("<>", self.__id_reload) self.text.unbind("<>", self.__id_print) # Break cycles self.editwin = None @@ -237,6 +239,35 @@ def save_a_copy(self, event): self.updaterecentfileslist(filename) return "break" + def reload(self, event): + """Reload the file from disk, discarding any unsaved changes. + + If the file has unsaved changes, ask the user to confirm. + """ + if not self.filename: + messagebox.showinfo( + "File Not Found", + "This window has no associated file to reload.", + parent=self.text) + self.text.focus_set() + return "break" + + if not self.get_saved(): + confirm = messagebox.askokcancel( + title="Reload File", + message=f"Discard changes to {self.filename}?", + default=messagebox.CANCEL, + parent=self.text) + if not confirm: + self.text.focus_set() + return "break" + + # Reload the file + self.loadfile(self.filename) + + self.text.focus_set() + return "break" + def writefile(self, filename): text = self.fixnewlines() chars = self.encode(text) diff --git a/Lib/idlelib/mainmenu.py b/Lib/idlelib/mainmenu.py index 91a32cebb513f9..6162b07a33a65e 100644 --- a/Lib/idlelib/mainmenu.py +++ b/Lib/idlelib/mainmenu.py @@ -31,6 +31,7 @@ ('_Save', '<>'), ('Save _As...', '<>'), ('Save Cop_y As...', '<>'), + ('_Reload from Disk', '<>'), None, ('Prin_t Window', '<>'), None, diff --git a/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst b/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst new file mode 100644 index 00000000000000..972f88637c74fd --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2025-11-14-20-58-55.gh-issue-44968.rL9dK3.rst @@ -0,0 +1,3 @@ +Add "Reload from Disk" menu item to IDLE's File menu. This allows users to +easily reload a file from disk, discarding any unsaved changes in the editor. +Patch by Shamil Abdulaev.