Compare commits

...

5 Commits

Author SHA1 Message Date
Will Miao
bf32d8b6fd chore(release): bump version to v1.1.2 2026-06-17 09:57:37 +08:00
Will Miao
8299881024 refactor(sidebar): remove pin/unpin and global hide, use per-page hide only
- Remove pin/unpin and auto-hide hover mechanism (isPinned, isHovering,
  hoverTimeout, showSidebar/hideSidebar, updateAutoHideState, etc.)
- Remove global show_folder_sidebar setting (SettingsManager,
  PageControls, recipes, backend default)
- Simplify sidebar visibility to a single per-page toggle:
  · Dedicated chevron-left button in header to hide sidebar
  · Edge indicator (chevron-right) to restore when hidden
  · No dropdown, no hover area, no pin button
- Add _migrateOldSettings() to convert old sidebarPinned and
  show_folder_sidebar states to per-page sidebarDisabled
- Fix sidebar flicker on page load: CSS defaults to off-screen,
  JS explicitly sets .visible or .hidden-by-setting
- Remove obsolete CSS classes: auto-hide, hover-active, collapsed
- Remove i18n keys: pinSidebar, unpinSidebar, moreOptions
- Update test mocks for the new initialize() interface
2026-06-17 09:49:24 +08:00
Will Miao
da02268196 fix(css): add top margin to stat-cards container for consistent spacing 2026-06-17 08:24:03 +08:00
Will Miao
8c4b9a1e70 fix(metadata-sync): persist not-found flags to SQLite cache on deleted-provider path
When a model is already classified as civitai_deleted=True via
.metadata.json but re-enters the failure block through the
civarchive/sqlite provider path (not the default provider),
needs_save was never set to True because civitai_api_not_found
and sqlite_attempted were both False. The flags were never
persisted to SQLite, causing the model to be re-fetched on
every restart.

Also demoted duplicate INFO/ERROR logging in fetch_and_update_model
to DEBUG (the use case already logs at WARNING), and added
exc_info=True to the fetch_all_civitai error handler.
2026-06-17 08:22:24 +08:00
Will Miao
0906c484e9 fix: actually halt bulk operations on cancel — frontend AbortController + backend guards (#986) 2026-06-17 07:20:32 +08:00
34 changed files with 301 additions and 788 deletions

View File

@@ -6,20 +6,21 @@
"Scott R" "Scott R"
], ],
"allSupporters": [ "allSupporters": [
"megakirbs",
"Brennok", "Brennok",
"Insomnia Art Designs", "Insomnia Art Designs",
"2018cfh", "2018cfh",
"megakirbs",
"Arlecchino Shion", "Arlecchino Shion",
"Rob Williams", "Rob Williams",
"W+K+White", "W+K+White",
"$MetaSamsara",
"wackop", "wackop",
"Phil", "Phil",
"Carl G.", "Carl G.",
"Charles Blakemore", "Charles Blakemore",
"stone9k", "stone9k",
"itismyelement", "itismyelement",
"$MetaSamsara", "Mozzel",
"Gingko Biloba", "Gingko Biloba",
"Kiba", "Kiba",
"onesecondinosaur", "onesecondinosaur",
@@ -31,17 +32,25 @@
"ClockDaemon", "ClockDaemon",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg", "Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"レプサイ",
"Michael Wong",
"runte3221", "runte3221",
"Illrigger", "Illrigger",
"Tom Corrigan", "Tom Corrigan",
"JackieWang",
"FreelancerZ", "FreelancerZ",
"fnkylove",
"Echo", "Echo",
"Lilleman",
"Robert Stacey", "Robert Stacey",
"PM",
"Edgar Tejeda", "Edgar Tejeda",
"Fraser Cross", "Fraser Cross",
"Liam MacDougal", "Liam MacDougal",
@@ -51,7 +60,7 @@
"Marc Whiffen", "Marc Whiffen",
"Skalabananen", "Skalabananen",
"Birdy", "Birdy",
"Mozzel", "quarz",
"Reno Lam", "Reno Lam",
"JSST", "JSST",
"sig", "sig",
@@ -64,17 +73,18 @@
"KD", "KD",
"Omnidex", "Omnidex",
"Nazono_hito", "Nazono_hito",
"Melville Parrish",
"daniel dove", "daniel dove",
"Lustre",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"JW Sin", "JW Sin",
"Alex", "Alex",
"SG",
"carozzz", "carozzz",
"Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
@@ -84,44 +94,44 @@
"Wolffen", "Wolffen",
"James Todd", "James Todd",
"Wicked Choices by ASLPro3D", "Wicked Choices by ASLPro3D",
"FinalyFree",
"Steven Pfeiffer", "Steven Pfeiffer",
"レプサイ",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Tak", "Tak",
"Lisster", "Lisster",
"Michael Wong",
"Big Red", "Big Red",
"whudunit", "whudunit",
"Luc Job",
"dl0901dm", "dl0901dm",
"JackieWang", "corde",
"fnkylove", "Nick Walker",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"Bishoujoker", "Bishoujoker",
"Lilleman",
"PM",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
"Tori",
"wildnut", "wildnut",
"Aleksander Wujczyk", "Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"BadassArabianMofo", "BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"quarz",
"Greg", "Greg",
"Akira_HentAI",
"lmsupporter", "lmsupporter",
"andrew.tappan", "andrew.tappan",
"Greenmoustache",
"zounic", "zounic",
"wfpearl", "wfpearl",
"Eldithor",
"Jack B Nimble", "Jack B Nimble",
"Melville Parrish",
"Lustre",
"JaxMax", "JaxMax",
"contrite831", "contrite831",
"bh", "bh",
"Marlon Daniels", "Jwk0205",
"Starkselle", "Starkselle",
"Olive",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"greebles", "greebles",
@@ -130,21 +140,17 @@
"Gooohokrbe", "Gooohokrbe",
"OldBones", "OldBones",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree",
"Matt Wenzel", "Matt Wenzel",
"Weasyl", "Weasyl",
"Lex Song", "Lex Song",
"Cory Paza", "Cory Paza",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Zach Gonser", "Zach Gonser",
"Serge Bekenkamp",
"Jimmy Ledbetter", "Jimmy Ledbetter",
"Luc Job",
"Philip Hempel", "Philip Hempel",
"corde",
"Nick Walker",
"dan", "dan",
"aai", "aai",
"Tori",
"otaku fra", "otaku fra",
"jean jahren", "jean jahren",
"MiraiKuriyamaSy", "MiraiKuriyamaSy",
@@ -154,7 +160,6 @@
"Sangheili460", "Sangheili460",
"MagnaInsomnia", "MagnaInsomnia",
"Karl P.", "Karl P.",
"Akira_HentAI",
"Gordon Cole", "Gordon Cole",
"Adam Taylor", "Adam Taylor",
"AbstractAss", "AbstractAss",
@@ -166,21 +171,19 @@
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"Luc", "Luc",
"Greenmoustache", "ProtonPrince",
"DiffDuck",
"Jackthemind", "Jackthemind",
"fancypants", "fancypants",
"Eldithor",
"Joboshy", "Joboshy",
"Digital", "Digital",
"takyamtom", "takyamtom",
"Bohemian Corporal", "Bohemian Corporal",
"Dan", "Dan",
"Jwk0205",
"Bro Xie", "Bro Xie",
"yer fey", "yer fey",
"batblue", "batblue",
"carey6409", "carey6409",
"Olive",
"太郎 ゲーム", "太郎 ゲーム",
"Roslynd", "Roslynd",
"jinxedx", "jinxedx",
@@ -196,11 +199,11 @@
"Frank Nitty", "Frank Nitty",
"Magic Noob", "Magic Noob",
"Christopher Michel", "Christopher Michel",
"Serge Bekenkamp",
"DougPeterson", "DougPeterson",
"LeoZero", "LeoZero",
"Antonio Pontes", "Antonio Pontes",
"ApathyJones", "ApathyJones",
"Bruce",
"Julian V", "Julian V",
"Steven Owens", "Steven Owens",
"nahinahi9", "nahinahi9",
@@ -210,11 +213,10 @@
"Mouthlessman", "Mouthlessman",
"Paul Kroll", "Paul Kroll",
"Bas Imagineer", "Bas Imagineer",
"John Statham",
"yuxz69", "yuxz69",
"esthe", "esthe",
"decoy", "decoy",
"ProtonPrince",
"DiffDuck",
"elu3199", "elu3199",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
@@ -228,13 +230,16 @@
"Ranzitho", "Ranzitho",
"Gus", "Gus",
"MJG", "MJG",
"David LaVallee",
"linnfrey", "linnfrey",
"IamAyam", "IamAyam",
"skaterb949", "skaterb949",
"Josef Lanzl", "Josef Lanzl",
"Nerezza", "Nerezza",
"sanborondon",
"confiscated Zyra", "confiscated Zyra",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"Taylor Funk",
"aezin", "aezin",
"jcay015", "jcay015",
"Gerald Welly", "Gerald Welly",
@@ -250,7 +255,6 @@
"Pronredn", "Pronredn",
"a _", "a _",
"Jeff", "Jeff",
"Bruce",
"lh qwe", "lh qwe",
"James Coleman", "James Coleman",
"conner", "conner",
@@ -260,15 +264,14 @@
"Princess Bright Eyes", "Princess Bright Eyes",
"Dušan Ryban", "Dušan Ryban",
"Felipe dos Santos", "Felipe dos Santos",
"Sam",
"sjon kreutz", "sjon kreutz",
"John Statham",
"Douglas Gaspar", "Douglas Gaspar",
"Metryman55", "Metryman55",
"AlexDuKaNa", "AlexDuKaNa",
"George", "George",
"dw", "dw",
"地獄の禄", "地獄の禄",
"David LaVallee",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia", "Gamalonia",
@@ -287,14 +290,16 @@
"kudari", "kudari",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"epicgamer0020690", "epicgamer0020690",
"Joshua Porrata",
"SuBu",
"Richard", "Richard",
"奚明 刘", "奚明 刘",
"Andrew", "Andrew",
"Brian M", "Brian M",
"Robert Wegemund", "Robert Wegemund",
"sanborondon", "Littlehuggy",
"준희 김", "준희 김",
"Taylor Funk", "Brian Buie",
"Thought2Form", "Thought2Form",
"Kevin Picco", "Kevin Picco",
"Sadlip", "Sadlip",
@@ -305,9 +310,13 @@
"Joshua Gray", "Joshua Gray",
"Mattssn", "Mattssn",
"Mikko Hemilä", "Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree", "Jamie Ogletree",
"Temikus",
"Artokun",
"Michael Taylor", "Michael Taylor",
"Martial", "Martial",
"Michael Anthony Scott",
"Emil Andersson", "Emil Andersson",
"Ouro Boros", "Ouro Boros",
"Atilla Berke Pekduyar", "Atilla Berke Pekduyar",
@@ -318,9 +327,10 @@
"Davaitamin", "Davaitamin",
"Rops Alot", "Rops Alot",
"tedcor", "tedcor",
"Sam",
"Fotek Design", "Fotek Design",
"Ace Ventura", "Ace Ventura",
"四糸凜音",
"Nihongasuki",
"LarsesFPC", "LarsesFPC",
"MadSpin", "MadSpin",
"inbijiburu", "inbijiburu",
@@ -330,9 +340,7 @@
"dc7431", "dc7431",
"ken", "ken",
"Crocket", "Crocket",
"Joshua Porrata",
"keemun", "keemun",
"SuBu",
"RedPIXel", "RedPIXel",
"Wind", "Wind",
"Nexus", "Nexus",
@@ -349,18 +357,23 @@
"KitKatM", "KitKatM",
"socrasteeze", "socrasteeze",
"OrganicArtifact", "OrganicArtifact",
"ResidentDeviant",
"MudkipMedkitz", "MudkipMedkitz",
"deanbrian", "deanbrian",
"Alex Wortman", "Alex Wortman",
"Cody", "Cody",
"emadsultan", "emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Vir", "Vir",
"Skyfire83",
"Adam Rinehart",
"gzmzmvp", "gzmzmvp",
"Littlehuggy",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"Draven T", "Draven T",
"mrjuan", "mrjuan",
"Brian Buie",
"Eric Whitney", "Eric Whitney",
"Joey Callahan", "Joey Callahan",
"Aquatic Coffee", "Aquatic Coffee",
@@ -373,16 +386,12 @@
"Theerat Jiramate", "Theerat Jiramate",
"Focuschannel", "Focuschannel",
"Noah", "Noah",
"Jacob McDaniel",
"X", "X",
"Sloan Steddy", "Sloan Steddy",
"Temikus",
"Artokun",
"hexxish", "hexxish",
"Derek Baker", "Derek Baker",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"Michael Anthony Scott",
"Nathan", "Nathan",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Pat Hen", "Pat Hen",
@@ -391,8 +400,6 @@
"Jordan Shaw", "Jordan Shaw",
"g unit", "g unit",
"Srdb", "Srdb",
"四糸凜音",
"Nihongasuki",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
"uwutismxd", "uwutismxd",
@@ -400,17 +407,10 @@
"zenobeus", "zenobeus",
"ryoma", "ryoma",
"Stryker", "Stryker",
"ResidentDeviant",
"Ginnie", "Ginnie",
"Raku", "Raku",
"smart.edge5178", "smart.edge5178",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard", "Menard",
"Skyfire83",
"Adam Rinehart",
"Pitpe11", "Pitpe11",
"TheD1rtyD03", "TheD1rtyD03",
"moonpetal", "moonpetal",
@@ -423,6 +423,8 @@
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast", "DarkRoast",
"letzte", "letzte",
"Nasty+Hobbit", "Nasty+Hobbit",
@@ -437,9 +439,11 @@
"Wolfe7D1", "Wolfe7D1",
"blikkies", "blikkies",
"Chris", "Chris",
"Time Valentine",
"elleshar666", "elleshar666",
"Shock Shockor", "Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"Goldwaters", "Goldwaters",
"Kauffy", "Kauffy",
"Zude", "Zude",
@@ -456,6 +460,7 @@
"Billy Gladky", "Billy Gladky",
"Michael Scott", "Michael Scott",
"Probis", "Probis",
"Solixer",
"Wes Sims", "Wes Sims",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"Donor4115", "Donor4115",
@@ -474,6 +479,8 @@
"Whitepinetrader", "Whitepinetrader",
"POPPIN", "POPPIN",
"nanana", "nanana",
"Alex",
"Karru",
"ChaChanoKo", "ChaChanoKo",
"ghoulars", "ghoulars",
"null", "null",
@@ -489,8 +496,6 @@
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
"quantenmecha",
"Jason+Nash",
"BillyBoy84", "BillyBoy84",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
@@ -499,13 +504,14 @@
"JBsuede", "JBsuede",
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
"Time Valentine",
"Christian Schäfer", "Christian Schäfer",
"りん あめ", "りん あめ",
"Михал Михалыч",
"Matt", "Matt",
"Locrospiel",
"Frogmilk", "Frogmilk",
"SPJ", "SPJ",
"Kor",
"Joseph Hanson",
"Kyron Mahan", "Kyron Mahan",
"Bryan Rutkowski", "Bryan Rutkowski",
"TBitz33", "TBitz33",
@@ -521,7 +527,6 @@
"Jimmy Borup", "Jimmy Borup",
"Paul Hartsuyker", "Paul Hartsuyker",
"elitassj", "elitassj",
"Solixer",
"Pete Pain", "Pete Pain",
"Jacob Winter", "Jacob Winter",
"Ryan Presley Ng", "Ryan Presley Ng",
@@ -553,6 +558,10 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"nickname",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo", "2turbo",
"Somebody", "Somebody",
"Balut+Omelette", "Balut+Omelette",
@@ -576,9 +585,7 @@
"Tan+Huynh", "Tan+Huynh",
"D", "D",
"Dark_Pest", "Dark_Pest",
"Alex",
"Jacky+Ho", "Jacky+Ho",
"Karru",
"generic404", "generic404",
"abattoirblues", "abattoirblues",
"zounik", "zounik",
@@ -593,24 +600,24 @@
"G", "G",
"Ronan Delevacq", "Ronan Delevacq",
"ja s", "ja s",
"Leslie Andrew Ridings",
"Doug Mason", "Doug Mason",
"Jeremy Townsend", "Jeremy Townsend",
"Dave Abraham", "Dave Abraham",
"Joaquin Hierrezuelo", "Joaquin Hierrezuelo",
"Locrospiel",
"Sean voets", "Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"Jarrid Lee", "Jarrid Lee",
"Poophead27 Blyat", "Poophead27 Blyat",
"Kor",
"Joseph Hanson",
"John Rednoulf", "John Rednoulf",
"Spire", "Spire",
"AZ Party Oasis",
"Boba Smith", "Boba Smith",
"Devil Lude", "Devil Lude",
"David Murcko", "David Murcko",
"MR.Bear", "MR.Bear",
"Jack Dole", "Jack Dole",
"matt",
"somethingtosay8", "somethingtosay8",
"ivistorm", "ivistorm",
"max blo", "max blo",
@@ -627,6 +634,7 @@
"Tigon", "Tigon",
"BastardSama", "BastardSama",
"mercur", "mercur",
"SkibidiRizzler",
"Tania Nayelli Fernandez", "Tania Nayelli Fernandez",
"Draconach", "Draconach",
"Yavizu3d", "Yavizu3d",
@@ -635,6 +643,7 @@
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Вячеслав Маринин", "Вячеслав Маринин",
"Marcos Tortosa Carmona",
"Dkommander22", "Dkommander22",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
@@ -679,6 +688,11 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Jim",
"Monix", "Monix",
"Trolinka", "Trolinka",
"IshouI;_;", "IshouI;_;",
@@ -707,9 +721,6 @@
"ExLightSaber", "ExLightSaber",
"YaboiRay", "YaboiRay",
"Drizzly", "Drizzly",
"Sildoren",
"Darvidous",
"Seon+Song",
"Nebuleux", "Nebuleux",
"Join+Chun", "Join+Chun",
"GDS+DEV", "GDS+DEV",
@@ -752,7 +763,6 @@
"Seraphy", "Seraphy",
"雨の心 落", "雨の心 落",
"AllTimeNoobie", "AllTimeNoobie",
"Leslie Andrew Ridings",
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
@@ -766,13 +776,11 @@
"Forbidden Atelier", "Forbidden Atelier",
"Thomas Sankowski", "Thomas Sankowski",
"DrB", "DrB",
"AZ Party Oasis",
"Adictedtohumping", "Adictedtohumping",
"Snorklebort", "Snorklebort",
"vinter",
"Towelie", "Towelie",
"TheFusion", "TheFusion",
"matt",
"dsffsdfsdfsdfsdfsdf",
"Jean-françois SEMA", "Jean-françois SEMA",
"3zS4QNQ4", "3zS4QNQ4",
"Terminuz", "Terminuz",
@@ -786,12 +794,14 @@
"jimyjomson", "jimyjomson",
"Borte", "Borte",
"JaeHyun Jang", "JaeHyun Jang",
"Homero Banda",
"Chase Kwon", "Chase Kwon",
"yyuvuvu", "yyuvuvu",
"Inyoshu", "Inyoshu",
"Chad Barnes", "Chad Barnes",
"Person Y", "Person Y",
"Nomki", "Nomki",
"inusanorthcape",
"James Ming", "James Ming",
"vanditking", "vanditking",
"kripitonga", "kripitonga",
@@ -804,7 +814,6 @@
"hannibal", "hannibal",
"Jo+Example", "Jo+Example",
"BrentBertram", "BrentBertram",
"inusanorthcape",
"eumelzocker", "eumelzocker",
"dxjaymz", "dxjaymz",
"L C", "L C",
@@ -812,5 +821,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 809 "totalCount": 818
} }

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
"cancelling": "Abbrechen...",
"unknown": "Unbekannt", "unknown": "Unbekannt",
"date": "Datum", "date": "Datum",
"version": "Version", "version": "Version",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
"moreOptions": "Weitere Optionen",
"collapseAll": "Alle Ordner einklappen", "collapseAll": "Alle Ordner einklappen",
"pinSidebar": "Sidebar anheften",
"unpinSidebar": "Sidebar lösen",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden", "hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen", "showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet", "sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
"cancelling": "Cancelling...",
"unknown": "Unknown", "unknown": "Unknown",
"date": "Date", "date": "Date",
"version": "Version", "version": "Version",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Root", "modelRoot": "Root",
"moreOptions": "More options",
"collapseAll": "Collapse All Folders", "collapseAll": "Collapse All Folders",
"pinSidebar": "Pin Sidebar",
"unpinSidebar": "Unpin Sidebar",
"hideOnThisPage": "Hide sidebar on this page", "hideOnThisPage": "Hide sidebar on this page",
"showSidebar": "Show sidebar", "showSidebar": "Show sidebar",
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page", "sidebarHiddenNotification": "Folder sidebar hidden on {page} page",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Cargando...", "loading": "Cargando...",
"cancelling": "Cancelando...",
"unknown": "Desconocido", "unknown": "Desconocido",
"date": "Fecha", "date": "Fecha",
"version": "Versión", "version": "Versión",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
"moreOptions": "Más opciones",
"collapseAll": "Colapsar todas las carpetas", "collapseAll": "Colapsar todas las carpetas",
"pinSidebar": "Fijar barra lateral",
"unpinSidebar": "Desfijar barra lateral",
"hideOnThisPage": "Ocultar barra lateral en esta página", "hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral", "showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}", "sidebarHiddenNotification": "Barra lateral oculta en la página {page}",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Chargement...", "loading": "Chargement...",
"cancelling": "Annulation...",
"unknown": "Inconnu", "unknown": "Inconnu",
"date": "Date", "date": "Date",
"version": "Version", "version": "Version",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
"moreOptions": "Plus d'options",
"collapseAll": "Réduire tous les dossiers", "collapseAll": "Réduire tous les dossiers",
"pinSidebar": "Épingler la barre latérale",
"unpinSidebar": "Désépingler la barre latérale",
"hideOnThisPage": "Masquer la barre latérale sur cette page", "hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale", "showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}", "sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
"cancelling": "מבטל...",
"unknown": "לא ידוע", "unknown": "לא ידוע",
"date": "תאריך", "date": "תאריך",
"version": "גרסה", "version": "גרסה",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"moreOptions": "אפשרויות נוספות",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
"pinSidebar": "נעל סרגל צד",
"unpinSidebar": "שחרר סרגל צד",
"hideOnThisPage": "הסתר סרגל צד בדף זה", "hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד", "showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}", "sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
"cancelling": "キャンセル中...",
"unknown": "不明", "unknown": "不明",
"date": "日付", "date": "日付",
"version": "バージョン", "version": "バージョン",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"moreOptions": "その他のオプション",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除",
"hideOnThisPage": "このページでサイドバーを非表示", "hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示", "showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています", "sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
"cancelling": "취소 중...",
"unknown": "알 수 없음", "unknown": "알 수 없음",
"date": "날짜", "date": "날짜",
"version": "버전", "version": "버전",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"moreOptions": "더 많은 옵션",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
"pinSidebar": "사이드바 고정",
"unpinSidebar": "사이드바 고정 해제",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기", "hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시", "showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다", "sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
"cancelling": "Отмена...",
"unknown": "Неизвестно", "unknown": "Неизвестно",
"date": "Дата", "date": "Дата",
"version": "Версия", "version": "Версия",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"moreOptions": "Дополнительные параметры",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
"pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель",
"hideOnThisPage": "Скрыть боковую панель на этой странице", "hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель", "showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}", "sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
"cancelling": "取消中...",
"unknown": "未知", "unknown": "未知",
"date": "日期", "date": "日期",
"version": "版本", "version": "版本",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"moreOptions": "更多选项",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
"pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏",
"hideOnThisPage": "隐藏此页面侧边栏", "hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏", "showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏", "sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
"cancelling": "取消中...",
"unknown": "未知", "unknown": "未知",
"date": "日期", "date": "日期",
"version": "版本", "version": "版本",
@@ -955,10 +956,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"moreOptions": "更多選項",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
"pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄",
"hideOnThisPage": "隱藏此頁面側邊欄", "hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄", "showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏", "sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",

View File

@@ -1861,7 +1861,9 @@ class ModelCivitaiHandler:
return web.json_response(result) return web.json_response(result)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error(
"Error in fetch_all_civitai for %ss: %s", self._service.model_type, exc "Error in fetch_all_civitai for %ss: %s",
self._service.model_type, exc,
exc_info=True,
) )
return web.Response(text=str(exc), status=500) return web.Response(text=str(exc), status=500)

View File

@@ -264,6 +264,14 @@ class MetadataSyncService:
model_data["last_checked_at"] = datetime.now().timestamp() model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True needs_save = True
# When the model was already classified as "not on CivitAI" via
# .metadata.json (civitai_deleted=True) but the SQLite cache is
# stale (because the pre-fix code never persisted these flags),
# ensure the flags are written to the scanner cache + SQLite.
if not needs_save and model_data.get("civitai_deleted") is True:
model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True
# Save metadata if any state was updated # Save metadata if any state was updated
if needs_save: if needs_save:
data_to_save = model_data.copy() data_to_save = model_data.copy()
@@ -272,6 +280,7 @@ class MetadataSyncService:
if "last_checked_at" not in data_to_save: if "last_checked_at" not in data_to_save:
data_to_save["last_checked_at"] = datetime.now().timestamp() data_to_save["last_checked_at"] = datetime.now().timestamp()
await self._metadata_manager.save_metadata(file_path, data_to_save) await self._metadata_manager.save_metadata(file_path, data_to_save)
await update_cache_func(file_path, file_path, data_to_save)
default_error = ( default_error = (
"CivitAI model is deleted and metadata archive DB is not enabled" "CivitAI model is deleted and metadata archive DB is not enabled"
@@ -291,11 +300,9 @@ class MetadataSyncService:
f"Error fetching metadata: {resolved_error} " f"Error fetching metadata: {resolved_error} "
f"(file={os.path.basename(file_path)}, sha256={sha256})" f"(file={os.path.basename(file_path)}, sha256={sha256})"
) )
is_model_not_found = "Model not found" in resolved_error # Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
if is_expected_offline_error(resolved_error) or is_model_not_found: # so this level is demoted to DEBUG to avoid duplicate user-visible logging.
logger.info(error_msg) logger.debug(error_msg)
else:
logger.error(error_msg)
return False, error_msg return False, error_msg
model_data["from_civitai"] = True model_data["from_civitai"] = True

View File

@@ -532,6 +532,13 @@ class ModelScanner:
if not scan_result or not getattr(self, '_persistent_cache', None): if not scan_result or not getattr(self, '_persistent_cache', None):
return return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
"after cancellation"
)
return
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index) hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: try:
@@ -705,14 +712,20 @@ class ModelScanner:
# Determine the page type based on model type # Determine the page type based on model type
# Scan for new data # Scan for new data
scan_result = await self._gather_model_data() scan_result = await self._gather_model_data()
await self._apply_scan_result(scan_result) if not self.is_cancelled():
await self._save_persistent_cache(scan_result) await self._apply_scan_result(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan') await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
logger.info( logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, " f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
f"found {len(scan_result.raw_data)} models" f"found {len(scan_result.raw_data)} models"
) )
else:
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
f"after {time.time() - start_time:.2f} seconds"
)
except Exception as e: except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}") logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
# Ensure cache is at least an empty structure on error # Ensure cache is at least an empty structure on error
@@ -1096,6 +1109,13 @@ class ModelScanner:
if scan_result is None: if scan_result is None:
return return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _apply_scan_result "
"after cancellation"
)
return
self._hash_index = scan_result.hash_index self._hash_index = scan_result.hash_index
self._tags_count = dict(scan_result.tags_count) self._tags_count = dict(scan_result.tags_count)
self._excluded_models = list(scan_result.excluded_models) self._excluded_models = list(scan_result.excluded_models)
@@ -1764,6 +1784,13 @@ class ModelScanner:
""" """
if not file_paths or self._cache is None: if not file_paths or self._cache is None:
return False return False
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping cache update "
"after cancelled bulk delete"
)
return False
try: try:
# Get all models that need to be removed from cache # Get all models that need to be removed from cache

View File

@@ -91,7 +91,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"autoplay_on_hover": False, "autoplay_on_hover": False,
"display_density": "default", "display_density": "default",
"card_info_display": "always", "card_info_display": "always",
"show_folder_sidebar": True,
"include_trigger_words": False, "include_trigger_words": False,
"compact_mode": False, "compact_mode": False,
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(), "priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.1" version = "1.1.2"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -8,7 +8,7 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-3); margin: var(--space-3) 0;
} }
.stat-card { .stat-card {

View File

@@ -8,69 +8,28 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
overflow: hidden; overflow: hidden;
transition: var(--transition-slow);
flex-shrink: 0; flex-shrink: 0;
z-index: var(--z-overlay); z-index: var(--z-overlay);
box-shadow: var(--shadow-header); box-shadow: var(--shadow-header);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
/* Default state: hidden off-screen */ /* Default: hidden off-screen — prevents flash before JS runs */
transform: translateX(-100%); transform: translateX(-100%);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.folder-sidebar.hidden-by-setting {
display: none !important;
}
/* Visible state */
.folder-sidebar.visible { .folder-sidebar.visible {
transform: translateX(0); transform: translateX(0);
opacity: 1; opacity: 1;
pointer-events: all; pointer-events: all;
} }
/* Auto-hide states */ .folder-sidebar.hidden-by-setting {
.folder-sidebar.auto-hide {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.folder-sidebar.auto-hide.hover-active {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
.folder-sidebar.collapsed {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
/* Hover detection area for auto-hide */
.sidebar-hover-area {
position: fixed;
top: 68px;
left: 0;
width: 20px;
height: calc(100vh - 88px);
z-index: calc(var(--z-overlay) - 1);
background: transparent;
pointer-events: all;
}
.sidebar-hover-area.hidden-by-setting {
display: none !important; display: none !important;
} }
.sidebar-hover-area.disabled {
pointer-events: none;
}
.sidebar-header { .sidebar-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -151,65 +110,6 @@
display: none; display: none;
} }
/* ===== Sidebar More Options Dropdown ===== */
.sidebar-more-dropdown {
position: absolute;
top: 100%;
right: 8px;
min-width: 190px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: var(--shadow-lg);
z-index: calc(var(--z-overlay) + 20);
display: none;
overflow: hidden;
margin-top: 2px;
}
.sidebar-more-dropdown.open {
display: block;
animation: dropdownFadeIn 0.15s ease;
}
@keyframes dropdownFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.sidebar-dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
font-size: 0.85em;
color: var(--text-color);
transition: var(--transition-base);
white-space: nowrap;
}
.sidebar-dropdown-item:hover {
background: var(--lora-surface);
}
.sidebar-dropdown-item i {
width: 16px;
text-align: center;
color: var(--text-muted);
font-size: 0.9em;
flex-shrink: 0;
}
.sidebar-dropdown-item:hover i {
color: var(--text-color);
}
.sidebar-dropdown-item.disabled {
opacity: 0.4;
pointer-events: none;
}
/* ===== Sidebar Hidden Indicator (left edge) ===== */ /* ===== Sidebar Hidden Indicator (left edge) ===== */
.sidebar-hidden-indicator { .sidebar-hidden-indicator {
position: fixed; position: fixed;
@@ -630,7 +530,7 @@
opacity: 0.3; opacity: 0.3;
} }
/* Responsive Design */ /* Responsive Design — Mobile: overlay when shown */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.folder-sidebar { .folder-sidebar {
top: 68px; top: 68px;
@@ -640,13 +540,9 @@
height: calc(100vh - 88px); height: calc(100vh - 88px);
z-index: calc(var(--z-overlay) + 10); z-index: calc(var(--z-overlay) + 10);
} }
.folder-sidebar.collapsed { /* Mobile overlay when sidebar is shown */
transform: translateX(-100%); .folder-sidebar.visible::before {
}
/* Mobile overlay */
.folder-sidebar:not(.collapsed)::before {
content: ''; content: '';
position: fixed; position: fixed;
top: 0; top: 0;
@@ -665,11 +561,11 @@
max-width: 280px; max-width: 280px;
left: 0px; left: 0px;
} }
.sidebar-breadcrumb-nav { .sidebar-breadcrumb-nav {
font-size: 0.8em; font-size: 0.8em;
} }
.sidebar-breadcrumb-item { .sidebar-breadcrumb-item {
padding: 3px 6px; padding: 3px 6px;
} }

View File

@@ -468,17 +468,21 @@ export class BaseModelApiClient {
} }
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {
const abortController = new AbortController();
try { try {
state.loadingManager.show( state.loadingManager.show(
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`, `${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
0 0
); );
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild); url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url); const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
@@ -494,6 +498,10 @@ export class BaseModelApiClient {
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success'); showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
console.error('Refresh failed:', error); console.error('Refresh failed:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error'); showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
} finally { } finally {
@@ -948,13 +956,19 @@ export class BaseModelApiClient {
throw new Error('No model IDs provided'); throw new Error('No model IDs provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.show('Checking for updates...', 0); state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, { const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
model_ids: modelIds, model_ids: modelIds,
force force
@@ -979,6 +993,10 @@ export class BaseModelApiClient {
return payload; return payload;
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for models:', error); console.error('Error refreshing updates for models:', error);
throw error; throw error;
} finally { } finally {
@@ -991,13 +1009,19 @@ export class BaseModelApiClient {
throw new Error('No folder path provided'); throw new Error('No folder path provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.show('Checking for updates...', 0); state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, { const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
folder_path: folderPath, folder_path: folderPath,
force force
@@ -1022,6 +1046,10 @@ export class BaseModelApiClient {
return payload; return payload;
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for folder:', error); console.error('Error refreshing updates for folder:', error);
throw error; throw error;
} finally { } finally {
@@ -1471,15 +1499,21 @@ export class BaseModelApiClient {
throw new Error('No file paths provided'); throw new Error('No file paths provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`); state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.bulkDelete, { const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
file_paths: filePaths file_paths: filePaths
}) })
@@ -1502,6 +1536,10 @@ export class BaseModelApiClient {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`); throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
} }
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
console.log(`Bulk delete cancelled by user for ${this.apiConfig.config.displayName.toLowerCase()}s`);
return { success: false, cancelled: true };
}
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error); console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
throw error; throw error;
} finally { } finally {

View File

@@ -17,12 +17,8 @@ export class SidebarManager {
this.treeData = {}; this.treeData = {};
this.selectedPath = ''; this.selectedPath = '';
this.expandedNodes = new Set(); this.expandedNodes = new Set();
this.isVisible = true;
this.isPinned = false;
this.apiClient = null; this.apiClient = null;
this.openDropdown = null; this.openDropdown = null;
this.hoverTimeout = null;
this.isHovering = false;
this.isInitialized = false; this.isInitialized = false;
this.displayMode = 'tree'; // 'tree' or 'list' this.displayMode = 'tree'; // 'tree' or 'list'
this.foldersList = []; this.foldersList = [];
@@ -35,9 +31,7 @@ export class SidebarManager {
this.folderTreeElement = null; this.folderTreeElement = null;
this.currentDropTarget = null; this.currentDropTarget = null;
this.lastPageControls = null; this.lastPageControls = null;
this.isDisabledBySetting = false;
this.isDisabledByPage = false; this.isDisabledByPage = false;
this.isMoreDropdownOpen = false;
this.initializationPromise = null; this.initializationPromise = null;
this.isCreatingFolder = false; this.isCreatingFolder = false;
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态 this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
@@ -48,12 +42,7 @@ export class SidebarManager {
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this); this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this); this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this); this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
this.handlePinToggle = this.handlePinToggle.bind(this);
this.handleCollapseAll = this.handleCollapseAll.bind(this); this.handleCollapseAll = this.handleCollapseAll.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.handleHoverAreaEnter = this.handleHoverAreaEnter.bind(this);
this.handleHoverAreaLeave = this.handleHoverAreaLeave.bind(this);
this.updateContainerMargin = this.updateContainerMargin.bind(this); this.updateContainerMargin = this.updateContainerMargin.bind(this);
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this); this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
this.handleFolderListClick = this.handleFolderListClick.bind(this); this.handleFolderListClick = this.handleFolderListClick.bind(this);
@@ -70,9 +59,7 @@ export class SidebarManager {
this.handleSidebarDrop = this.handleSidebarDrop.bind(this); this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this); this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this); this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
this.handleMoreToggle = this.handleMoreToggle.bind(this); this.handleHideToggle = this.handleHideToggle.bind(this);
this.handleMoreDropdownItemClick = this.handleMoreDropdownItemClick.bind(this);
this.handleDocumentClickForMore = this.handleDocumentClickForMore.bind(this);
this.getPageDisplayName = this.getPageDisplayName.bind(this); this.getPageDisplayName = this.getPageDisplayName.bind(this);
} }
@@ -81,12 +68,6 @@ export class SidebarManager {
} }
async initialize(pageControls, options = {}) { async initialize(pageControls, options = {}) {
const { forceInitialize = false } = options;
if (this.isDisabledBySetting && !forceInitialize) {
return;
}
// Clean up previous initialization if exists // Clean up previous initialization if exists
if (this.isInitialized) { if (this.isInitialized) {
this.cleanup(); this.cleanup();
@@ -99,25 +80,15 @@ export class SidebarManager {
|| pageControls?.sidebarApiClient || pageControls?.sidebarApiClient
|| getModelApiClient(); || getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
this.setupEventHandlers(); this.setupEventHandlers();
this.initializeDragAndDrop(); this.initializeDragAndDrop();
this.updateSidebarTitle(); this.updateSidebarTitle();
this.restoreSidebarState(); this.restoreSidebarState();
// Re-apply DOM visibility now that per-page state is known // Apply DOM visibility based on per-page state
this.updateDomVisibility(!this.isDisabledBySetting); this.updateDomVisibility();
await this.loadFolderTree(); await this.loadFolderTree();
if (this.isDisabledBySetting && !forceInitialize) {
this.cleanup();
return;
}
this.restoreSelectedFolder(); this.restoreSelectedFolder();
// Apply final state with animation after everything is loaded
this.applyFinalSidebarState();
// Update container margin based on initial sidebar state // Update container margin based on initial sidebar state
this.updateContainerMargin(); this.updateContainerMargin();
@@ -128,12 +99,6 @@ export class SidebarManager {
cleanup() { cleanup() {
if (!this.isInitialized) return; if (!this.isInitialized) return;
// Clear any pending timeouts
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
// Clean up event handlers // Clean up event handlers
this.removeEventHandlers(); this.removeEventHandlers();
@@ -151,11 +116,6 @@ export class SidebarManager {
this.sidebarDragHandlersInitialized = false; this.sidebarDragHandlersInitialized = false;
} }
const moreDropdown = document.getElementById('sidebarMoreDropdown');
if (moreDropdown) {
moreDropdown.classList.remove('open');
}
this.isMoreDropdownOpen = false;
this.hideSidebarHiddenIndicator(); this.hideSidebarHiddenIndicator();
// Reset state // Reset state
@@ -165,7 +125,6 @@ export class SidebarManager {
this.selectedPath = ''; this.selectedPath = '';
this.expandedNodes = new Set(); this.expandedNodes = new Set();
this.openDropdown = null; this.openDropdown = null;
this.isHovering = false;
this.isDisabledByPage = false; this.isDisabledByPage = false;
this.apiClient = null; this.apiClient = null;
this.isInitialized = false; this.isInitialized = false;
@@ -185,19 +144,13 @@ export class SidebarManager {
} }
removeEventHandlers() { removeEventHandlers() {
const pinToggleBtn = document.getElementById('sidebarPinToggle');
const collapseAllBtn = document.getElementById('sidebarCollapseAll'); const collapseAllBtn = document.getElementById('sidebarCollapseAll');
const folderTree = document.getElementById('sidebarFolderTree'); const folderTree = document.getElementById('sidebarFolderTree');
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav'); const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
const sidebarHeader = document.getElementById('sidebarHeader'); const sidebarHeader = document.getElementById('sidebarHeader');
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle'); const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle'); const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
if (pinToggleBtn) {
pinToggleBtn.removeEventListener('click', this.handlePinToggle);
}
if (collapseAllBtn) { if (collapseAllBtn) {
collapseAllBtn.removeEventListener('click', this.handleCollapseAll); collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
} }
@@ -212,14 +165,6 @@ export class SidebarManager {
if (sidebarHeader) { if (sidebarHeader) {
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick); sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
} }
if (sidebar) {
sidebar.removeEventListener('mouseenter', this.handleMouseEnter);
sidebar.removeEventListener('mouseleave', this.handleMouseLeave);
}
if (hoverArea) {
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
}
// Remove document click handler // Remove document click handler
document.removeEventListener('click', this.handleDocumentClick); document.removeEventListener('click', this.handleDocumentClick);
@@ -234,17 +179,10 @@ export class SidebarManager {
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle); recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
} }
const moreToggle = document.getElementById('sidebarMoreToggle'); const hideToggle = document.getElementById('sidebarHideToggle');
if (moreToggle) { if (hideToggle) {
moreToggle.removeEventListener('click', this.handleMoreToggle); hideToggle.removeEventListener('click', this.handleHideToggle);
} }
const moreDropdown = document.getElementById('sidebarMoreDropdown');
if (moreDropdown) {
moreDropdown.removeEventListener('click', this.handleMoreDropdownItemClick);
}
document.removeEventListener('click', this.handleDocumentClickForMore);
} }
initializeDragAndDrop() { initializeDragAndDrop() {
@@ -919,60 +857,6 @@ export class SidebarManager {
this.currentDropTarget = null; this.currentDropTarget = null;
} }
async init() {
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
this.setupEventHandlers();
this.initializeDragAndDrop();
this.updateSidebarTitle();
this.restoreSidebarState();
await this.loadFolderTree();
this.restoreSelectedFolder();
// Apply final state with animation after everything is loaded
this.applyFinalSidebarState();
// Update container margin based on initial sidebar state
this.updateContainerMargin();
}
setInitialSidebarState() {
if (this.isDisabledBySetting) return;
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (!sidebar || !hoverArea) return;
// Get stored pin state
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
this.isPinned = isPinned;
// Sidebar starts hidden by default (CSS handles this)
// Just set up the hover area state
if (window.innerWidth <= 1024) {
hoverArea.classList.add('disabled');
} else if (this.isPinned) {
hoverArea.classList.add('disabled');
} else {
hoverArea.classList.remove('disabled');
}
}
applyFinalSidebarState() {
if (this.isDisabledBySetting) return;
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
this.updateAutoHideState();
});
}
updateSidebarTitle() { updateSidebarTitle() {
const sidebarTitle = document.getElementById('sidebarTitle'); const sidebarTitle = document.getElementById('sidebarTitle');
if (sidebarTitle) { if (sidebarTitle) {
@@ -987,12 +871,6 @@ export class SidebarManager {
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick); sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
} }
// Pin toggle button
const pinToggleBtn = document.getElementById('sidebarPinToggle');
if (pinToggleBtn) {
pinToggleBtn.addEventListener('click', this.handlePinToggle);
}
// Collapse all button // Collapse all button
const collapseAllBtn = document.getElementById('sidebarCollapseAll'); const collapseAllBtn = document.getElementById('sidebarCollapseAll');
if (collapseAllBtn) { if (collapseAllBtn) {
@@ -1018,34 +896,18 @@ export class SidebarManager {
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick); sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
} }
// Hover detection for auto-hide
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (sidebar) {
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
}
if (hoverArea) {
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
}
// Close sidebar when clicking outside on mobile // Close sidebar when clicking outside on mobile
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (window.innerWidth <= 1024 && this.isVisible) { if (window.innerWidth <= 1024) {
const sidebar = document.getElementById('folderSidebar'); const sidebar = document.getElementById('folderSidebar');
if (sidebar && !sidebar.contains(e.target) && !this.isDisabledByPage) {
if (sidebar && !sidebar.contains(e.target)) { sidebar.classList.remove('visible');
this.hideSidebar();
} }
} }
}); });
// Handle window resize // Handle window resize
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
this.updateAutoHideState();
this.updateContainerMargin(); this.updateContainerMargin();
}); });
@@ -1074,18 +936,11 @@ export class SidebarManager {
}); });
} }
// More options dropdown // Dedicated hide sidebar button
const moreToggle = document.getElementById('sidebarMoreToggle'); const hideToggle = document.getElementById('sidebarHideToggle');
if (moreToggle) { if (hideToggle) {
moreToggle.addEventListener('click', this.handleMoreToggle); hideToggle.addEventListener('click', this.handleHideToggle);
} }
const moreDropdown = document.getElementById('sidebarMoreDropdown');
if (moreDropdown) {
moreDropdown.addEventListener('click', this.handleMoreDropdownItemClick);
}
document.addEventListener('click', this.handleDocumentClickForMore);
} }
handleDocumentClick(event) { handleDocumentClick(event) {
@@ -1102,14 +957,9 @@ export class SidebarManager {
} }
} }
handlePinToggle(event) { handleHideToggle(event) {
event.stopPropagation(); event.stopPropagation();
this.isPinned = !this.isPinned; this.toggleHideOnThisPage();
this.updateAutoHideState();
this.updatePinButton();
this.updateMoreDropdownLabels();
this.saveSidebarState();
this.updateContainerMargin();
} }
handleCollapseAll(event) { handleCollapseAll(event) {
@@ -1119,102 +969,13 @@ export class SidebarManager {
this.saveExpandedState(); this.saveExpandedState();
} }
handleMouseEnter() { // ===== Sidebar visibility (per-page) and container margin =====
this.isHovering = true;
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
if (!this.isPinned) {
this.showSidebar();
}
}
handleMouseLeave() {
this.isHovering = false;
if (!this.isPinned) {
this.hoverTimeout = setTimeout(() => {
if (!this.isHovering) {
this.hideSidebar();
}
}, 300);
}
}
handleHoverAreaEnter() {
if (!this.isPinned) {
this.showSidebar();
}
}
handleHoverAreaLeave() {
// Let the sidebar's mouse leave handler deal with hiding
}
showSidebar() {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !this.isPinned) {
sidebar.classList.add('hover-active');
this.isVisible = true;
this.updateContainerMargin();
}
}
hideSidebar() {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !this.isPinned) {
sidebar.classList.remove('hover-active');
this.isVisible = false;
this.updateContainerMargin();
}
}
updateAutoHideState() {
if (this.isDisabledBySetting || this.isDisabledByPage) return;
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (!sidebar || !hoverArea) return;
if (window.innerWidth <= 1024) {
// Mobile: always use collapsed state
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
sidebar.classList.add('collapsed');
hoverArea.classList.add('disabled');
this.isVisible = false;
} else if (this.isPinned) {
// Desktop pinned: always visible
sidebar.classList.remove('auto-hide', 'collapsed', 'hover-active');
sidebar.classList.add('visible');
hoverArea.classList.add('disabled');
this.isVisible = true;
} else {
// Desktop auto-hide: use hover detection
sidebar.classList.remove('collapsed', 'visible');
sidebar.classList.add('auto-hide');
hoverArea.classList.remove('disabled');
if (this.isHovering) {
sidebar.classList.add('hover-active');
this.isVisible = true;
} else {
sidebar.classList.remove('hover-active');
this.isVisible = false;
}
}
// Update container margin when sidebar state changes
this.updateContainerMargin();
}
// New method to update container margin based on sidebar state
updateContainerMargin() { updateContainerMargin() {
const container = document.querySelector('.container'); const container = document.querySelector('.container');
const sidebar = document.getElementById('folderSidebar'); const sidebar = document.getElementById('folderSidebar');
if (!container || !sidebar || this.isDisabledBySetting) return; if (!container || !sidebar) return;
// Always reset margin first — needed when transitioning from visible to hidden // Always reset margin first — needed when transitioning from visible to hidden
container.style.marginLeft = ''; container.style.marginLeft = '';
@@ -1222,194 +983,40 @@ export class SidebarManager {
// When per-page disabled, skip adjustment but margin is already reset // When per-page disabled, skip adjustment but margin is already reset
if (this.isDisabledByPage) return; if (this.isDisabledByPage) return;
// Only adjust margin if sidebar is visible and pinned // Sidebar is visible — adjust margin if we need room
if ((this.isPinned || this.isHovering) && this.isVisible) { const sidebarWidth = sidebar.offsetWidth;
const sidebarWidth = sidebar.offsetWidth; const viewportWidth = window.innerWidth;
const viewportWidth = window.innerWidth; const containerWidth = container.offsetWidth;
const containerWidth = container.offsetWidth;
// Check if there's enough space for both sidebar and container if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
// We need: sidebar width + container width + some padding < viewport width container.style.marginLeft = `${sidebarWidth + 10}px`;
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
// Not enough space, push container to the right
container.style.marginLeft = `${sidebarWidth + 10}px`;
}
} }
} }
updateDomVisibility(enabled) { updateDomVisibility() {
// Per-page disable adds on top of global setting const isHidden = this.isDisabledByPage;
const isVisible = enabled && !this.isDisabledByPage;
const sidebar = document.getElementById('folderSidebar'); const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (sidebar) { if (sidebar) {
sidebar.classList.toggle('hidden-by-setting', !isVisible); sidebar.classList.toggle('visible', !isHidden);
sidebar.setAttribute('aria-hidden', (!isVisible).toString()); sidebar.classList.toggle('hidden-by-setting', isHidden);
sidebar.setAttribute('aria-hidden', isHidden.toString());
} }
if (hoverArea) { // Show or hide the "sidebar hidden" edge indicator
hoverArea.classList.toggle('hidden-by-setting', !isVisible); if (isHidden) {
if (!isVisible) {
hoverArea.classList.add('disabled');
}
}
// Show or hide the "sidebar hidden" notification
if (enabled && this.isDisabledByPage) {
this.showSidebarHiddenIndicator(); this.showSidebarHiddenIndicator();
} else { } else {
this.hideSidebarHiddenIndicator(); this.hideSidebarHiddenIndicator();
} }
} }
async setSidebarEnabled(enabled) {
this.isDisabledBySetting = !enabled;
this.updateDomVisibility(enabled);
const shouldForceInitialization = !enabled && !this.isInitialized;
const needsInitialization = !this.isInitialized || shouldForceInitialization;
if (this.lastPageControls && needsInitialization) {
if (!this.initializationPromise) {
this.initializationPromise = this.initialize(this.lastPageControls, {
forceInitialize: shouldForceInitialization,
})
.catch((error) => {
console.error('Sidebar initialization failed:', error);
})
.finally(() => {
this.initializationPromise = null;
});
}
await this.initializationPromise;
} else if (this.initializationPromise) {
await this.initializationPromise;
}
if (!enabled) {
this.isHovering = false;
this.isVisible = false;
const container = document.querySelector('.container');
if (container) {
container.style.marginLeft = '';
}
if (this.isInitialized) {
this.updateBreadcrumbs();
this.updateSidebarHeader();
}
return;
}
if (this.isInitialized) {
this.updateAutoHideState();
}
}
updatePinButton() {
const pinBtn = document.getElementById('sidebarPinToggle');
if (pinBtn) {
pinBtn.classList.toggle('active', this.isPinned);
pinBtn.title = this.isPinned
? translate('sidebar.unpinSidebar')
: translate('sidebar.pinSidebar');
}
}
// ===== More Options Dropdown =====
handleMoreToggle(event) {
event.stopPropagation();
const dropdown = document.getElementById('sidebarMoreDropdown');
if (!dropdown) return;
this.isMoreDropdownOpen = !dropdown.classList.contains('open');
dropdown.classList.toggle('open', this.isMoreDropdownOpen);
this.updateMoreDropdownLabels();
}
handleMoreDropdownItemClick(event) {
const item = event.target.closest('.sidebar-dropdown-item');
if (!item) return;
const action = item.dataset.action;
if (!action) return;
const dropdown = document.getElementById('sidebarMoreDropdown');
if (dropdown) {
dropdown.classList.remove('open');
this.isMoreDropdownOpen = false;
}
switch (action) {
case 'toggle-pin':
this.handlePinToggle(event);
break;
case 'toggle-hide':
this.toggleHideOnThisPage();
break;
}
}
handleDocumentClickForMore(event) {
const dropdown = document.getElementById('sidebarMoreDropdown');
const toggle = document.getElementById('sidebarMoreToggle');
if (!dropdown || !toggle) return;
if (!dropdown.contains(event.target) && !toggle.contains(event.target)) {
dropdown.classList.remove('open');
this.isMoreDropdownOpen = false;
}
}
updateMoreDropdownLabels() {
const pinLabel = document.getElementById('sidebarMorePinLabel');
if (pinLabel) {
pinLabel.textContent = this.isPinned
? translate('sidebar.unpinSidebar')
: translate('sidebar.pinSidebar');
}
const hideItem = document.querySelector('.sidebar-dropdown-item[data-action="toggle-hide"]');
if (hideItem) {
const hideIcon = hideItem.querySelector('i');
const hideLabel = hideItem.querySelector('span');
if (this.isDisabledByPage) {
hideLabel.textContent = translate('sidebar.showSidebar');
if (hideIcon) {
hideIcon.className = 'fas fa-eye';
}
} else {
hideLabel.textContent = translate('sidebar.hideOnThisPage');
if (hideIcon) {
hideIcon.className = 'fas fa-eye-slash';
}
}
}
}
toggleHideOnThisPage() { toggleHideOnThisPage() {
this.isDisabledByPage = !this.isDisabledByPage; this.isDisabledByPage = !this.isDisabledByPage;
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage); setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
this.updateDomVisibility(!this.isDisabledBySetting); this.updateDomVisibility();
this.updateAutoHideState();
this.updateContainerMargin(); this.updateContainerMargin();
this.updateMoreDropdownLabels();
if (!this.isDisabledByPage) {
this.hideSidebarHiddenIndicator();
} else {
showToast(
'sidebar.sidebarHiddenNotification',
{ page: this.getPageDisplayName() },
'info',
`Sidebar hidden on ${this.getPageDisplayName()} page`
);
}
} }
getPageDisplayName() { getPageDisplayName() {
@@ -1733,11 +1340,6 @@ export class SidebarManager {
// Reload models with new filter // Reload models with new filter
await this.pageControls.resetAndReload(); await this.pageControls.resetAndReload();
// Auto-hide sidebar on mobile after selection
if (window.innerWidth <= 1024) {
this.hideSidebar();
}
} }
handleFolderListClick(event) { handleFolderListClick(event) {
@@ -2047,65 +1649,55 @@ export class SidebarManager {
} }
} }
toggleSidebar() {
const sidebar = document.getElementById('folderSidebar');
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
if (!sidebar) return;
this.isVisible = !this.isVisible;
if (this.isVisible) {
sidebar.classList.remove('collapsed');
sidebar.classList.add('visible');
} else {
sidebar.classList.remove('visible');
sidebar.classList.add('collapsed');
}
if (toggleBtn) {
toggleBtn.classList.toggle('active', this.isVisible);
}
this.saveSidebarState();
}
closeSidebar() {
const sidebar = document.getElementById('folderSidebar');
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
if (!sidebar) return;
this.isVisible = false;
sidebar.classList.remove('visible');
sidebar.classList.add('collapsed');
if (toggleBtn) {
toggleBtn.classList.remove('active');
}
this.saveSidebarState();
}
restoreSidebarState() { restoreSidebarState() {
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true); // Migration: old pin/unpin and global hide → per-page hide
this._migrateOldSettings();
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []); const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree' const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true); const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false); this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
this.isPinned = isPinned;
this.expandedNodes = new Set(expandedPaths); this.expandedNodes = new Set(expandedPaths);
this.displayMode = displayMode; this.displayMode = displayMode;
this.recursiveSearchEnabled = recursiveSearchEnabled; this.recursiveSearchEnabled = recursiveSearchEnabled;
this.updatePinButton();
this.updateDisplayModeButton(); this.updateDisplayModeButton();
this.updateCollapseAllButton(); this.updateCollapseAllButton();
this.updateSearchRecursiveOption(); this.updateSearchRecursiveOption();
this.updateRecursiveToggleButton(); this.updateRecursiveToggleButton();
} }
/**
* One-time migration: old pin/unpin and global show_folder_sidebar → per-page hide
* - sidebarPinned=false (was auto-hide) → sidebarDisabled=true for that page
* - show_folder_sidebar=false (global) → sidebarDisabled=true for ALL pages
*/
_migrateOldSettings() {
if (getStorageItem('_sidebar_migration_done')) return;
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
// 1. Migrate global hide setting to per-page
if (state?.global?.settings?.show_folder_sidebar === false) {
PAGES.forEach(p => setStorageItem(`${p}_sidebarDisabled`, true));
}
// 2. Migrate unpinned (auto-hide) to per-page hide
PAGES.forEach(p => {
const wasPinned = getStorageItem(`${p}_sidebarPinned`, true);
const alreadyDisabled = getStorageItem(`${p}_sidebarDisabled`, false);
if (wasPinned === false && !alreadyDisabled) {
// Was auto-hide → user didn't want sidebar taking space
setStorageItem(`${p}_sidebarDisabled`, true);
}
// Clean up old keys
localStorage.removeItem(`${p}_sidebarPinned`);
});
setStorageItem('_sidebar_migration_done', true);
}
restoreSelectedFolder() { restoreSelectedFolder() {
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`); const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
if (activeFolder && typeof activeFolder === 'string') { if (activeFolder && typeof activeFolder === 'string') {
@@ -2118,11 +1710,6 @@ export class SidebarManager {
this.updateSidebarHeader(); this.updateSidebarHeader();
this.updateBreadcrumbs(); // Always update breadcrumbs this.updateBreadcrumbs(); // Always update breadcrumbs
} }
// Removed hidden class toggle since breadcrumbs are always visible now
}
saveSidebarState() {
setStorageItem(`${this.pageType}_sidebarPinned`, this.isPinned);
} }
saveExpandedState() { saveExpandedState() {
@@ -2134,7 +1721,7 @@ export class SidebarManager {
} }
async refresh() { async refresh() {
if (this.isDisabledBySetting || !this.isInitialized) { if (!this.isInitialized) {
return; return;
} }

View File

@@ -93,8 +93,7 @@ export class PageControls {
async initSidebarManager() { async initSidebarManager() {
try { try {
this.sidebarManager.setHostPageControls(this); this.sidebarManager.setHostPageControls(this);
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false; await this.sidebarManager.initialize(this);
await this.sidebarManager.setSidebarEnabled(shouldShowSidebar);
} catch (error) { } catch (error) {
console.error('Failed to initialize SidebarManager:', error); console.error('Failed to initialize SidebarManager:', error);
} }
@@ -664,13 +663,6 @@ export class PageControls {
} }
this.updateActionButtonStates(); this.updateActionButtonStates();
if (this.sidebarManager) {
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
console.error('Failed to update sidebar visibility:', error);
});
}
} }
suspendInteractiveModes() { suspendInteractiveModes() {

View File

@@ -611,7 +611,9 @@ export class BulkManager {
const result = await apiClient.bulkDeleteModels(filePaths); const result = await apiClient.bulkDeleteModels(filePaths);
if (result.success) { if (result?.cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
} else if (result.success) {
const currentConfig = this.getCurrentDisplayConfig(); const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.deletedSuccessfully', { showToast('toast.models.deletedSuccessfully', {
count: result.deleted_count, count: result.deleted_count,

View File

@@ -73,7 +73,7 @@ export class LoadingManager {
if (this.onCancelCallback) { if (this.onCancelCallback) {
this.onCancelCallback(); this.onCancelCallback();
this.cancelButton.disabled = true; this.cancelButton.disabled = true;
this.cancelButton.textContent = translate('common.status.loading', {}, 'Loading...'); this.cancelButton.textContent = translate('common.status.cancelling', {}, 'Cancelling...');
} }
}; };

View File

@@ -15,7 +15,6 @@ import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js'; import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js'; import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
import { bannerService } from './BannerService.js'; import { bannerService } from './BannerService.js';
import { sidebarManager } from '../components/SidebarManager.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']); const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
@@ -884,12 +883,6 @@ export class SettingsManager {
cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always'; cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always';
} }
const showFolderSidebarCheckbox = document.getElementById('showFolderSidebar');
if (showFolderSidebarCheckbox) {
const showSidebarSetting = state.global.settings.show_folder_sidebar;
showFolderSidebarCheckbox.checked = showSidebarSetting !== false;
}
// Set model card footer action // Set model card footer action
const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction'); const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction');
if (modelCardFooterActionSelect) { if (modelCardFooterActionSelect) {
@@ -2949,12 +2942,6 @@ export class SettingsManager {
const showVersionOnCard = state.global.settings.show_version_on_card !== false; const showVersionOnCard = state.global.settings.show_version_on_card !== false;
document.body.classList.toggle('hide-card-version', !showVersionOnCard); document.body.classList.toggle('hide-card-version', !showVersionOnCard);
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
console.error('Failed to apply sidebar visibility setting:', error);
});
}
} }
} }

View File

@@ -95,8 +95,7 @@ class RecipeManager {
async _initSidebar() { async _initSidebar() {
try { try {
sidebarManager.setHostPageControls(this.pageControls); sidebarManager.setHostPageControls(this.pageControls);
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false; await sidebarManager.initialize(this.pageControls);
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
} catch (error) { } catch (error) {
console.error('Failed to initialize recipe sidebar:', error); console.error('Failed to initialize recipe sidebar:', error);
} }

View File

@@ -36,7 +36,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
autoplay_on_hover: false, autoplay_on_hover: false,
display_density: 'default', display_density: 'default',
card_info_display: 'always', card_info_display: 'always',
show_folder_sidebar: true,
model_name_display: 'model_name', model_name_display: 'model_name',
lora_syntax_format: 'legacy', lora_syntax_format: 'legacy',
model_card_footer_action: 'example_images', model_card_footer_action: 'example_images',

View File

@@ -42,7 +42,12 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
onStart?.({ displayName, loadingMessage }); onStart?.({ displayName, loadingMessage });
state.loadingManager?.showSimpleLoading?.(loadingMessage); state.loadingManager?.showSimpleLoading?.(loadingMessage);
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
const abortController = new AbortController();
state.loadingManager?.showCancelButton?.(() => {
apiClient.cancelTask();
abortController.abort();
});
let status = 'success'; let status = 'success';
let records = []; let records = [];
@@ -52,6 +57,7 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
const response = await fetch(apiConfig.endpoints.refreshUpdates, { const response = await fetch(apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ force: false }) body: JSON.stringify({ force: false })
}); });
@@ -81,6 +87,11 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
await resetAndReload(false); await resetAndReload(false);
} catch (err) { } catch (err) {
if (err?.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
status = 'cancelled';
return { status: 'cancelled', displayName, records: [], error: null };
}
status = 'error'; status = 'error';
error = err instanceof Error ? err : new Error(String(err)); error = err instanceof Error ? err : new Error(String(err));
console.error('Error checking model updates:', error); console.error('Error checking model updates:', error);
@@ -126,7 +137,12 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
); );
state.loadingManager?.showSimpleLoading?.(loadingMessage); state.loadingManager?.showSimpleLoading?.(loadingMessage);
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
const abortController = new AbortController();
state.loadingManager?.showCancelButton?.(() => {
apiClient.cancelTask();
abortController.abort();
});
let status = 'success'; let status = 'success';
let records = []; let records = [];
@@ -136,6 +152,7 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
const response = await fetch(apiConfig.endpoints.refreshUpdates, { const response = await fetch(apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ folder_path: folderPath, force: false }) body: JSON.stringify({ folder_path: folderPath, force: false })
}); });
@@ -165,6 +182,11 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
await resetAndReload(false); await resetAndReload(false);
} catch (err) { } catch (err) {
if (err?.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
status = 'cancelled';
return { status: 'cancelled', records: [], error: null };
}
status = 'error'; status = 'error';
error = err instanceof Error ? err : new Error(String(err)); error = err instanceof Error ? err : new Error(String(err));
console.error('Error checking folder model updates:', error); console.error('Error checking folder model updates:', error);

View File

@@ -1,6 +1,3 @@
<!-- Hover detection area -->
<div class="sidebar-hover-area" id="sidebarHoverArea"></div>
<!-- Folder Navigation Sidebar --> <!-- Folder Navigation Sidebar -->
<div class="folder-sidebar" id="folderSidebar"> <div class="folder-sidebar" id="folderSidebar">
<div class="sidebar-header" id="sidebarHeader"> <div class="sidebar-header" id="sidebarHeader">
@@ -15,23 +12,9 @@
<button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}"> <button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}">
<i class="fas fa-compress-alt"></i> <i class="fas fa-compress-alt"></i>
</button> </button>
<button class="sidebar-action-btn" id="sidebarPinToggle" title="{{ t('sidebar.unpinSidebar') }}"> <button class="sidebar-action-btn" id="sidebarHideToggle" title="{{ t('sidebar.hideOnThisPage') }}">
<i class="fas fa-thumbtack"></i> <i class="fas fa-chevron-left"></i>
</button> </button>
<button class="sidebar-action-btn" id="sidebarMoreToggle" title="{{ t('sidebar.moreOptions') }}">
<i class="fas fa-ellipsis-v"></i>
</button>
</div>
<!-- Dropdown menu for more options -->
<div class="sidebar-more-dropdown" id="sidebarMoreDropdown">
<div class="sidebar-dropdown-item" data-action="toggle-pin">
<i class="fas fa-thumbtack"></i>
<span id="sidebarMorePinLabel">{{ t('sidebar.pinSidebar') }}</span>
</div>
<div class="sidebar-dropdown-item" data-action="toggle-hide">
<i class="fas fa-eye-slash"></i>
<span>{{ t('sidebar.hideOnThisPage') }}</span>
</div>
</div> </div>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">

View File

@@ -480,24 +480,6 @@
<div class="settings-subsection-header"> <div class="settings-subsection-header">
<h4>{{ t('settings.sections.layoutSettings') }}</h4> <h4>{{ t('settings.sections.layoutSettings') }}</h4>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="showFolderSidebar">
{{ t('settings.layoutSettings.showFolderSidebar') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.showFolderSidebarHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="showFolderSidebar"
onchange="settingsManager.saveToggleSetting('showFolderSidebar', 'show_folder_sidebar')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">

View File

@@ -2190,6 +2190,7 @@ describe('Interaction-level regression coverage', () => {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }), body: JSON.stringify({ force: false }),
signal: expect.any(AbortSignal),
}); });
const updateResponse = await global.fetch.mock.results[1].value; const updateResponse = await global.fetch.mock.results[1].value;

View File

@@ -20,7 +20,7 @@ const downloadManagerMock = {
const sidebarManagerMock = { const sidebarManagerMock = {
setHostPageControls: vi.fn(), setHostPageControls: vi.fn(),
setSidebarEnabled: vi.fn(async () => { initialize: vi.fn(async () => {
sidebarManagerMock.isInitialized = true; sidebarManagerMock.isInitialized = true;
}), }),
refresh: vi.fn(async () => {}), refresh: vi.fn(async () => {}),
@@ -75,9 +75,6 @@ beforeEach(() => {
performModelUpdateCheckMock.mockResolvedValue({ status: 'success', displayName: 'LoRA', records: [] }); performModelUpdateCheckMock.mockResolvedValue({ status: 'success', displayName: 'LoRA', records: [] });
sidebarManagerMock.isInitialized = false; sidebarManagerMock.isInitialized = false;
sidebarManagerMock.setSidebarEnabled.mockImplementation(async (enabled) => {
sidebarManagerMock.isInitialized = enabled;
});
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,

View File

@@ -72,12 +72,6 @@ vi.mock('../../../static/js/managers/BannerService.js', () => ({
}, },
})); }));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setSidebarEnabled: vi.fn().mockResolvedValue(),
},
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js'; import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { state } from '../../../static/js/state/index.js'; import { state } from '../../../static/js/state/index.js';

View File

@@ -83,6 +83,15 @@ vi.mock('../../../static/js/api/recipeApi.js', () => ({
})), })),
})); }));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setHostPageControls: vi.fn(),
initialize: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
cleanup: vi.fn(),
},
}));
describe('RecipeManager', () => { describe('RecipeManager', () => {
let RecipeManager; let RecipeManager;
let pageState; let pageState;

View File

@@ -293,7 +293,8 @@ async def test_fetch_and_update_model_respects_deleted_without_archive():
assert "metadata archive DB is not enabled" in error assert "metadata archive DB is not enabled" in error
helpers.default_provider_factory.assert_not_awaited() helpers.default_provider_factory.assert_not_awaited()
helpers.metadata_manager.hydrate_model_data.assert_not_awaited() helpers.metadata_manager.hydrate_model_data.assert_not_awaited()
update_cache.assert_not_awaited() # Now update_cache_func IS called to persist the not-found flags to SQLite
update_cache.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio