Skip to content

Commit 320c9b0

Browse files
committed
fix(issue-6): round-5 — delete key, restart flow, single-line resx, data annotations
Fixes the four items from the v0.7.15 test report plus bugs found in review. VS Code extension: - Delete key was a silent no-op: native confirm() is blocked in webviews and the message omitted resourceGroup. Replace with a custom confirm modal, thread resourceGroup through, and reload the grid on success. Harden escapeJs to also HTML-encode &/</>/quotes so keys with those chars can't break the onclick. - "Restart Backend" reported false success: gate the success notification on an actual post-restart health check (statusBar.update() now returns bool; add LrmService.isHealthy()). - Edit modal ignored resourceGroup in multi-group projects: thread group through translateKey/getKeyDetails/saveEditedKey and key the details cache by group. - Stale ApiClient after restart (new port): repoint StatusBar, Dashboard, Settings and ResourceEditor panels via setApiClient/refreshClients at both the restart and resource-path-change sites. - lrmService.stop() no longer accumulates exit listeners (once() + captured ref). Core / API: - New .resx <data> entries were serialized single-line; normalize whitespace and re-indent on save so they match the standard multi-line format (all surfaces). - Detect localization keys in Data Annotation attributes ([Display(Name=..., ResourceType=typeof(...))], ErrorMessageResourceName/Type): new DataAnnotationExtractor, KeyReference.ResourceTypeClassName, group-aware missing-key resolution in CodeScanner, and ResourceTypeClassName in the API CodeReference + referenceProvider patterns. Tests: resx multi-line format, data-annotation extraction + scanner (single and multi group), delete/get/update controller coverage (single and multi group), deleteKeyMessage, backendHealth, escapeJs round-trip/injection, stale-client swap, and a real-binary restart integration test (skips in CI when unbundled).
1 parent 90ac04c commit 320c9b0

30 files changed

Lines changed: 1609 additions & 52 deletions

Controllers/ScanController.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ public ActionResult<ScanResponse> Scan([FromBody] ScanRequest? request)
8080
File = r.FilePath,
8181
Line = r.Line,
8282
Pattern = r.Pattern,
83-
Confidence = r.Confidence.ToString()
83+
Confidence = r.Confidence.ToString(),
84+
ResourceTypeClassName = r.ResourceTypeClassName
8485
}).ToList()
8586
}).ToList()
8687
});
@@ -159,7 +160,8 @@ public ActionResult<ScanResponse> ScanFile([FromBody] FileScanRequest request)
159160
File = r.FilePath,
160161
Line = r.Line,
161162
Pattern = r.Pattern,
162-
Confidence = r.Confidence.ToString()
163+
Confidence = r.Confidence.ToString(),
164+
ResourceTypeClassName = r.ResourceTypeClassName
163165
}).ToList()
164166
}).ToList()
165167
});
@@ -279,7 +281,8 @@ public ActionResult<KeyReferencesResponse> GetReferences(string keyName)
279281
File = r.FilePath,
280282
Line = r.Line,
281283
Pattern = r.Pattern,
282-
Confidence = r.Confidence.ToString()
284+
Confidence = r.Confidence.ToString(),
285+
ResourceTypeClassName = r.ResourceTypeClassName
283286
}).ToList()
284287
});
285288
}

LocalizationManager.Core/Backends/Resx/ResxResourceWriter.cs

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,18 @@ public void Write(ResourceFile file)
8080
WriteWithUniqueKeys(root, file.Entries);
8181
}
8282

83-
// Save with proper formatting using atomic write to prevent file corruption
83+
// Save with proper formatting using atomic write to prevent file corruption.
84+
// We re-indent the whole document on save (via NormalizeWhitespace) so newly
85+
// added <data> elements get the same multi-line layout as existing/hand-edited
86+
// entries instead of being collapsed onto a single line (issue #6). The
87+
// standard .resx layout is 2-space indentation, which is idempotent for
88+
// already-formatted files.
89+
NormalizeWhitespace(xdoc.Root!);
90+
8491
var tempPath = file.Language.FilePath + $".tmp.{Guid.NewGuid()}";
8592
try
8693
{
87-
xdoc.Save(tempPath);
94+
SaveIndented(xdoc, tempPath);
8895
File.Move(tempPath, file.Language.FilePath, overwrite: true);
8996
}
9097
finally
@@ -196,7 +203,24 @@ public string SerializeToString(ResourceFile file)
196203
root.Add(CreateDataElement(entry));
197204
}
198205

199-
return xdoc.Declaration + Environment.NewLine + xdoc.ToString();
206+
// Re-indent so new entries are multi-line (issue #6), consistent with Write().
207+
NormalizeWhitespace(root);
208+
209+
var settings = new XmlWriterSettings
210+
{
211+
Indent = true,
212+
IndentChars = " ",
213+
NewLineChars = "\n",
214+
OmitXmlDeclaration = false
215+
};
216+
// Use a UTF-8-reporting writer so the emitted declaration keeps encoding="utf-8"
217+
// (a plain StringWriter would force encoding="utf-16"), matching the on-disk file.
218+
using var sw = new Utf8StringWriter();
219+
using (var writer = XmlWriter.Create(sw, settings))
220+
{
221+
xdoc.Save(writer);
222+
}
223+
return sw.ToString();
200224
}
201225

202226
/// <summary>
@@ -330,6 +354,53 @@ private static void UpdateDataElement(XElement dataElement, ResourceEntry entry)
330354
}
331355
}
332356

357+
/// <summary>
358+
/// Removes whitespace-only text nodes that sit between elements so the document can
359+
/// be cleanly re-indented on save. Without this, existing files keep their original
360+
/// whitespace text nodes while newly-added elements have none, producing a mix of
361+
/// multi-line and single-line entries (issue #6). Text content inside a leaf element
362+
/// (e.g. the actual value text) is left untouched.
363+
/// </summary>
364+
private static void NormalizeWhitespace(XElement element)
365+
{
366+
// Only strip inter-element whitespace from container elements (those that have
367+
// child elements). Leaf elements like <value> hold the real text and are left as-is.
368+
if (element.HasElements)
369+
{
370+
foreach (var text in element.Nodes().OfType<XText>().ToList())
371+
{
372+
if (string.IsNullOrWhiteSpace(text.Value))
373+
{
374+
text.Remove();
375+
}
376+
}
377+
378+
foreach (var child in element.Elements())
379+
{
380+
NormalizeWhitespace(child);
381+
}
382+
}
383+
}
384+
385+
/// <summary>
386+
/// Saves the document with consistent 2-space indentation, preserving the
387+
/// declaration. Mirrors the layout produced by the standard .resx writer.
388+
/// </summary>
389+
private static void SaveIndented(XDocument xdoc, string path)
390+
{
391+
var settings = new XmlWriterSettings
392+
{
393+
Indent = true,
394+
IndentChars = " ",
395+
Encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
396+
NewLineChars = "\n",
397+
OmitXmlDeclaration = false
398+
};
399+
400+
using var writer = XmlWriter.Create(path, settings);
401+
xdoc.Save(writer);
402+
}
403+
333404
/// <summary>
334405
/// Creates a new data element for an entry.
335406
/// </summary>
@@ -388,4 +459,13 @@ private static bool IsValidCultureCode(string code, out CultureInfo? culture)
388459
return false;
389460
}
390461
}
462+
463+
/// <summary>
464+
/// StringWriter that reports UTF-8 as its encoding so XmlWriter emits
465+
/// <c>encoding="utf-8"</c> in the declaration instead of the default utf-16.
466+
/// </summary>
467+
private sealed class Utf8StringWriter : StringWriter
468+
{
469+
public override System.Text.Encoding Encoding => System.Text.Encoding.UTF8;
470+
}
391471
}

LocalizationManager.Core/Scanning/CodeScanner.cs

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public ScanResult Scan(
5151

5252
// Get all keys from resource files
5353
var resourceKeys = GetAllResourceKeys(resourceFiles);
54+
var keysByGroup = GetKeysByGroup(resourceFiles);
5455

5556
// Discover source files
5657
var allExtensions = _scanners.SelectMany(s => s.SupportedExtensions).Distinct();
@@ -87,7 +88,7 @@ public ScanResult Scan(
8788
Key = key,
8889
ReferenceCount = references.Count,
8990
References = references,
90-
ExistsInResources = resourceKeys.Contains(key),
91+
ExistsInResources = KeyExists(references, key, resourceKeys, keysByGroup),
9192
DefinedInLanguages = GetLanguagesForKey(resourceFiles, key)
9293
};
9394

@@ -158,6 +159,7 @@ public ScanResult ScanSingleFile(
158159

159160
// Get all keys from resource files
160161
var resourceKeys = GetAllResourceKeys(resourceFiles);
162+
var keysByGroup = GetKeysByGroup(resourceFiles);
161163

162164
// Detect file type and get appropriate scanner
163165
var extension = Path.GetExtension(filePath);
@@ -200,7 +202,7 @@ public ScanResult ScanSingleFile(
200202
Key = key,
201203
ReferenceCount = references.Count,
202204
References = references,
203-
ExistsInResources = resourceKeys.Contains(key),
205+
ExistsInResources = KeyExists(references, key, resourceKeys, keysByGroup),
204206
DefinedInLanguages = GetLanguagesForKey(resourceFiles, key)
205207
};
206208

@@ -276,6 +278,7 @@ public ScanResult ScanSingleFileContent(
276278

277279
// Get all keys from resource files
278280
var resourceKeys = GetAllResourceKeys(resourceFiles);
281+
var keysByGroup = GetKeysByGroup(resourceFiles);
279282

280283
// Detect file type and get appropriate scanner
281284
var extension = Path.GetExtension(filePath);
@@ -317,7 +320,7 @@ public ScanResult ScanSingleFileContent(
317320
Key = key,
318321
ReferenceCount = references.Count,
319322
References = references,
320-
ExistsInResources = resourceKeys.Contains(key),
323+
ExistsInResources = KeyExists(references, key, resourceKeys, keysByGroup),
321324
DefinedInLanguages = GetLanguagesForKey(resourceFiles, key)
322325
};
323326

@@ -523,6 +526,81 @@ private HashSet<string> GetAllResourceKeys(List<ResourceFile> resourceFiles)
523526
StringComparer.OrdinalIgnoreCase);
524527
}
525528

529+
/// <summary>
530+
/// Builds a map of resource-group BaseName -> set of keys defined in that group's
531+
/// default file(s). Used to resolve Data Annotation references that name a specific
532+
/// <c>ResourceType</c> against the correct group rather than the global union.
533+
/// </summary>
534+
private static Dictionary<string, HashSet<string>> GetKeysByGroup(List<ResourceFile> resourceFiles)
535+
{
536+
var map = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
537+
538+
var defaultFiles = resourceFiles.Where(f => f.Language.IsDefault).ToList();
539+
if (defaultFiles.Count == 0)
540+
defaultFiles = resourceFiles;
541+
542+
foreach (var file in defaultFiles)
543+
{
544+
var baseName = file.Language.BaseName;
545+
if (string.IsNullOrEmpty(baseName))
546+
continue;
547+
548+
if (!map.TryGetValue(baseName, out var set))
549+
{
550+
set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
551+
map[baseName] = set;
552+
}
553+
554+
foreach (var entry in file.Entries)
555+
set.Add(entry.Key);
556+
}
557+
558+
return map;
559+
}
560+
561+
/// <summary>
562+
/// Decides whether a key group exists in resources. References that name a
563+
/// specific <c>ResourceType</c> (Data Annotations) must be resolved against that
564+
/// group; untyped references fall back to the global union (<paramref name="allKeys"/>).
565+
/// </summary>
566+
private static bool KeyExists(
567+
IReadOnlyList<KeyReference> references,
568+
string key,
569+
HashSet<string> allKeys,
570+
Dictionary<string, HashSet<string>> keysByGroup)
571+
{
572+
// A key can be referenced both ways at once (e.g. `Resources.K` AND a
573+
// `[Display(..., ResourceType = typeof(R))]`). The key exists if ANY of its
574+
// references can resolve it:
575+
// - an untyped reference resolves against the global union;
576+
// - a typed reference resolves against its own group (or the union if that
577+
// group's resx is unknown, to avoid spurious "missing" noise).
578+
var hasUntyped = references.Any(r => string.IsNullOrEmpty(r.ResourceTypeClassName));
579+
if (hasUntyped && allKeys.Contains(key))
580+
{
581+
return true;
582+
}
583+
584+
foreach (var boundType in references
585+
.Select(r => r.ResourceTypeClassName)
586+
.Where(t => !string.IsNullOrEmpty(t))
587+
.Distinct())
588+
{
589+
var existsInBoundGroup = keysByGroup.TryGetValue(boundType!, out var groupKeys)
590+
? groupKeys.Contains(key)
591+
: allKeys.Contains(key);
592+
if (existsInBoundGroup)
593+
{
594+
return true;
595+
}
596+
}
597+
598+
// No reference could resolve the key. If there were only untyped references we
599+
// already returned false above via the allKeys check; this also covers the
600+
// all-typed case where none of the bound groups contain the key.
601+
return false;
602+
}
603+
526604
private List<string> GetLanguagesForKey(List<ResourceFile> resourceFiles, string key)
527605
{
528606
var languages = new List<string>();

LocalizationManager.Core/Scanning/Models/KeyReference.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,13 @@ public class KeyReference
4444
/// Warning message if confidence is low
4545
/// </summary>
4646
public string? Warning { get; set; }
47+
48+
/// <summary>
49+
/// For keys referenced via a Data Annotation attribute (e.g.
50+
/// <c>[Display(Name = "K", ResourceType = typeof(GlassResources))]</c>), the
51+
/// simple class name of the resource type (<c>GlassResources</c>). Maps to a
52+
/// resx group's BaseName so the key can be resolved against the correct group.
53+
/// Null for ordinary references that are not bound to a specific resource type.
54+
/// </summary>
55+
public string? ResourceTypeClassName { get; set; }
4756
}

LocalizationManager.Core/Scanning/Scanners/CSharpScanner.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ public override List<KeyReference> ScanContent(
6969
// Scan for indexer patterns
7070
ScanIndexerAccess(cleanedContent, originalContent, filePath, references, injectedLocalizerVariables);
7171

72+
// Scan for Data Annotation attributes that name a resource key + ResourceType,
73+
// e.g. [Display(Name = "K", ResourceType = typeof(GlassResources))].
74+
ScanDataAnnotations(cleanedContent, filePath, references);
75+
7276
// Scan for dynamic patterns (unless strict mode)
7377
if (!strictMode)
7478
{
@@ -198,6 +202,23 @@ private void ScanIndexerAccess(string content, string originalContent, string fi
198202
}
199203
}
200204

205+
private void ScanDataAnnotations(string content, string filePath, List<KeyReference> references)
206+
{
207+
foreach (var match in DataAnnotationExtractor.Extract(content))
208+
{
209+
references.Add(new KeyReference
210+
{
211+
Key = match.Key,
212+
FilePath = filePath,
213+
Line = match.Line,
214+
Pattern = match.Pattern,
215+
Context = match.Pattern,
216+
Confidence = ConfidenceLevel.High,
217+
ResourceTypeClassName = match.ResourceTypeClassName
218+
});
219+
}
220+
}
221+
201222
private void ScanDynamicPatterns(string content, string originalContent, string filePath, List<KeyReference> references, List<string> methodNames)
202223
{
203224
var escapedMethods = methodNames.Select(Regex.Escape);

0 commit comments

Comments
 (0)